Wanted: install it and the two office cameras are already there - but without the release carrying their admin password where anyone with the zip can read it. "Encode it" does not achieve that; anything the installer can decode, anyone holding the installer can decode. pkg/demo seals the camera list with AES-256-GCM under a key that is NOT in the package: a 120-bit unlock code minted when the bundle is sealed, given to whoever runs setup by voice or message, typed once. The code is random, so it is key material directly through SHA-256; a human- chosen passphrase would need a KDF and a dependency, 120 random bits do not. The sealed file contains the format marker and noise. Tested: the password and the host do not appear in it, a wrong code and a flipped byte are both refused as ErrWrongCode, every seal differs. behavision-demo-pack seals; it runs on the build machine and is never shipped. The code is printed once and stored nowhere. behavision-setup, on finding demo-cameras.enc beside the engine source, asks for the code BEFORE the ten-minute download so a mistyped one costs seconds, and adds the cameras at the end - through the running engine's own Add Camera endpoint, not by writing its file. The store's save() is what applies DPAPI to the password on Windows, so this is how the credential ends up encrypted and machine-bound on the demo PC rather than in cameras.json for anyone who can read ProgramData. It then marks the PC standalone, so the app opens on Live instead of asking for an installation code it will never get. Which found the gap that DPAPI only works if pywin32 is importable, and nothing had ever pulled it in - every Windows install to date would have logged the warning and written camera passwords in the clear. Added as a Windows-only dependency. Verified in a clean container: a wrong code refused, the right one unlocks two cameras, every install step passes, both cameras added through the API, standalone set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
544 lines
18 KiB
Go
544 lines
18 KiB
Go
// Command behavision-setup prepares a shop PC to run the recognition engine.
|
|
//
|
|
// It exists because the engine is Python and the rest of the product is Go.
|
|
// The Go halves cross-compile to Windows from any machine; the engine, frozen
|
|
// with PyInstaller, does not - PyInstaller bundles the interpreter and native
|
|
// wheels of the machine it runs on, so a frozen engine can only be built on
|
|
// Windows. That single fact was the whole reason a release could not be cut.
|
|
//
|
|
// So this installs the engine from source instead of shipping it frozen: find
|
|
// a Python, build a private virtual environment beside the database, install
|
|
// the engine into it, fetch the models, and record how to start it. Everything
|
|
// in the release can then be built anywhere.
|
|
//
|
|
// The trade, stated plainly because whoever runs this is standing in a shop:
|
|
// it needs Python and a working internet connection at install time, and it
|
|
// takes minutes rather than seconds. A frozen build needs neither. What it
|
|
// buys is a release that exists.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"bytes"
|
|
"encoding/json"
|
|
|
|
"github.com/loyaly/behavision-agent/pkg/config"
|
|
"github.com/loyaly/behavision-agent/pkg/demo"
|
|
"github.com/loyaly/behavision-agent/pkg/engine"
|
|
"github.com/loyaly/behavision-agent/pkg/paths"
|
|
)
|
|
|
|
// The engine needs 3.10; nothing here works below it and the failure would
|
|
// otherwise arrive as a syntax error deep inside a dependency.
|
|
const minMinor = 10
|
|
|
|
func main() {
|
|
if err := run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "\n Setup did not finish: %v\n\n", err)
|
|
pause()
|
|
os.Exit(1)
|
|
}
|
|
pause()
|
|
}
|
|
|
|
func run() error {
|
|
fmt.Println()
|
|
fmt.Println(" Behavision setup")
|
|
fmt.Println(" ----------------")
|
|
fmt.Println()
|
|
|
|
state := paths.StateRoot()
|
|
src, err := engineSource()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf(" engine source %s\n", src)
|
|
fmt.Printf(" install into %s\n", state)
|
|
fmt.Println()
|
|
|
|
if err := paths.EnsureState(); err != nil {
|
|
return fmt.Errorf("could not create %s: %w", state, err)
|
|
}
|
|
|
|
// A demo release ships its cameras sealed. Ask for the code NOW, before
|
|
// the ten-minute download, so a mistyped one costs seconds; the cameras
|
|
// are actually added at the end, through the running engine.
|
|
demoCams, err := unlockDemo(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if demoCams != nil {
|
|
step("Demo cameras", fmt.Sprintf("%d unlocked", len(demoCams)))
|
|
}
|
|
|
|
py, ver, err := findPython()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
step("Python", fmt.Sprintf("%s (%s)", ver, py))
|
|
|
|
venv := filepath.Join(state, "runtime")
|
|
if err := makeVenv(py, venv); err != nil {
|
|
return err
|
|
}
|
|
vpy := venvPython(venv)
|
|
step("Virtual environment", venv)
|
|
|
|
// The engine reads its settings from <state>/config/default.yaml and will
|
|
// seed that from beside its own code on first run - which works when its
|
|
// code is a checkout or a frozen folder and not when it is a package in
|
|
// site-packages, where there is no config beside it. Seeded here, from the
|
|
// copy the release ships. Never overwritten: an upgrade must not revert an
|
|
// operator's thresholds.
|
|
if err := seedConfig(src, state); err != nil {
|
|
return err
|
|
}
|
|
step("Settings", filepath.Join(state, "config", "default.yaml"))
|
|
|
|
// --upgrade so re-running after a new release replaces the engine rather
|
|
// than leaving the old one in place and reporting success.
|
|
if err := pipInstall(vpy, src); err != nil {
|
|
return err
|
|
}
|
|
step("Engine and dependencies", "installed")
|
|
|
|
if err := runEngine(vpy, "setup-models"); err != nil {
|
|
return fmt.Errorf("downloading the recognition models: %w", err)
|
|
}
|
|
step("Recognition models", "downloaded")
|
|
|
|
if err := writeConfig(vpy); err != nil {
|
|
return err
|
|
}
|
|
step("Startup settings", filepath.Join(state, "agent.json"))
|
|
|
|
// Proving it starts is the point. An installer that reports success and
|
|
// leaves a shop with an engine that will not run has done worse than
|
|
// failing: the failure surfaces later, to someone who did not install it.
|
|
if err := smokeTest(vpy, demoCams); err != nil {
|
|
return fmt.Errorf("the engine installed but would not start: %w", err)
|
|
}
|
|
step("Engine starts and answers", "verified")
|
|
if demoCams != nil {
|
|
step("Demo cameras", "added to the engine")
|
|
// No head office in a demo. Without this the app opens on "type an
|
|
// installation code" and sits there; with it, it opens on Live.
|
|
if err := markStandalone(); err != nil {
|
|
return err
|
|
}
|
|
step("Head office", "none - running on this PC only")
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
|
|
fmt.Println(" It appears in the system tray; right-click there to stop it.")
|
|
fmt.Println()
|
|
return nil
|
|
}
|
|
|
|
func step(label, detail string) {
|
|
fmt.Printf(" [ok] %-24s %s\n", label, detail)
|
|
}
|
|
|
|
// engineSource finds the Python source shipped beside this executable. Beside,
|
|
// not downloaded: the engine and the app must be the same release, and a
|
|
// version skew between them is the class of bug nobody can reproduce.
|
|
func engineSource() (string, error) {
|
|
candidates := []string{
|
|
filepath.Join(paths.InstallRoot(), "engine-src"),
|
|
filepath.Join(paths.InstallRoot(), "..", "engine-src"),
|
|
}
|
|
if wd, err := os.Getwd(); err == nil {
|
|
candidates = append(candidates, filepath.Join(wd, "engine-src"), wd)
|
|
}
|
|
for _, c := range candidates {
|
|
if _, err := os.Stat(filepath.Join(c, "pyproject.toml")); err == nil {
|
|
abs, _ := filepath.Abs(c)
|
|
return abs, nil
|
|
}
|
|
}
|
|
return "", errors.New("could not find the engine source (expected an " +
|
|
"engine-src folder with pyproject.toml beside this program). " +
|
|
"Unzip the whole release together rather than moving this file out of it")
|
|
}
|
|
|
|
// findPython returns the first interpreter that is new enough.
|
|
//
|
|
// `py -3` first on Windows: the launcher is what the official installer puts
|
|
// on PATH, and `python` there is often the Microsoft Store stub that prints an
|
|
// advert and exits 9009 instead of running anything.
|
|
func findPython() (string, string, error) {
|
|
type cand struct {
|
|
exe string
|
|
args []string
|
|
}
|
|
var cands []cand
|
|
if runtime.GOOS == "windows" {
|
|
cands = append(cands, cand{"py", []string{"-3"}})
|
|
}
|
|
cands = append(cands, cand{"python3", nil}, cand{"python", nil})
|
|
|
|
var tried []string
|
|
for _, c := range cands {
|
|
exe, err := exec.LookPath(c.exe)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
args := append(append([]string{}, c.args...), "-c",
|
|
"import sys;print('%d.%d'%sys.version_info[:2])")
|
|
out, err := exec.Command(exe, args...).Output()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
ver := strings.TrimSpace(string(out))
|
|
tried = append(tried, c.exe+" "+ver)
|
|
if major, minor, ok := parseVer(ver); ok && (major > 3 || (major == 3 && minor >= minMinor)) {
|
|
full := exe
|
|
if len(c.args) > 0 {
|
|
full = exe + " " + strings.Join(c.args, " ")
|
|
}
|
|
return full, "Python " + ver, nil
|
|
}
|
|
}
|
|
|
|
msg := "no Python 3.10 or newer was found on this PC.\n\n" +
|
|
" Install it from https://www.python.org/downloads/windows/\n" +
|
|
" and tick \"Add python.exe to PATH\" on the first screen,\n" +
|
|
" then run this again."
|
|
if len(tried) > 0 {
|
|
msg += "\n\n Found, but too old: " + strings.Join(tried, ", ")
|
|
}
|
|
return "", "", errors.New(msg)
|
|
}
|
|
|
|
func parseVer(s string) (int, int, bool) {
|
|
parts := strings.Split(s, ".")
|
|
if len(parts) < 2 {
|
|
return 0, 0, false
|
|
}
|
|
major, err1 := strconv.Atoi(parts[0])
|
|
minor, err2 := strconv.Atoi(parts[1])
|
|
return major, minor, err1 == nil && err2 == nil
|
|
}
|
|
|
|
// splitLauncher turns `py -3` back into a command and its arguments.
|
|
func splitLauncher(s string) (string, []string) {
|
|
f := strings.Fields(s)
|
|
if len(f) == 0 {
|
|
return s, nil
|
|
}
|
|
return f[0], f[1:]
|
|
}
|
|
|
|
func venvPython(venv string) string {
|
|
if runtime.GOOS == "windows" {
|
|
return filepath.Join(venv, "Scripts", "python.exe")
|
|
}
|
|
return filepath.Join(venv, "bin", "python")
|
|
}
|
|
|
|
// makeVenv builds the engine's own interpreter under the writable state root.
|
|
//
|
|
// A virtual environment rather than the system Python: a shop PC may have
|
|
// Python there for something else, and pinning numpy below 2.0 - which the
|
|
// engine requires - inside a shared interpreter is how you break the other
|
|
// thing months later, silently.
|
|
func makeVenv(py, venv string) error {
|
|
if _, err := os.Stat(venvPython(venv)); err == nil {
|
|
return nil // already built; pip below brings it up to date
|
|
}
|
|
exe, args := splitLauncher(py)
|
|
args = append(args, "-m", "venv", venv)
|
|
return stream(exec.Command(exe, args...), "creating the virtual environment")
|
|
}
|
|
|
|
func pipInstall(vpy, src string) error {
|
|
fmt.Println(" Installing the engine and its libraries. This downloads a few")
|
|
fmt.Println(" hundred megabytes and takes a while on a slow connection.")
|
|
fmt.Println()
|
|
if err := stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade",
|
|
"pip", "setuptools", "wheel"), "updating pip"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// A wheel if the release ships one - nothing to build on the shop PC, and
|
|
// pip never has to touch the folder the release was unzipped into.
|
|
//
|
|
// That matters more than it sounds: `pip install <folder>` makes setuptools
|
|
// write behavision.egg-info INTO that folder, and the folder is read-only
|
|
// whenever the release was unzipped somewhere sensible - Program Files, or
|
|
// the shared drive INSTALL.txt says is fine. Found by running this in a
|
|
// container with the source mounted read-only: "could not create
|
|
// 'behavision.egg-info': Read-only file system". Falling back to source
|
|
// copies it somewhere writable first, for the same reason.
|
|
if wheels, _ := filepath.Glob(filepath.Join(src, "behavision-*.whl")); len(wheels) > 0 {
|
|
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]),
|
|
"installing the engine")
|
|
}
|
|
tmp, err := os.MkdirTemp("", "behavision-src-")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
if err := copyTree(src, tmp); err != nil {
|
|
return fmt.Errorf("staging the engine source: %w", err)
|
|
}
|
|
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", tmp),
|
|
"installing the engine")
|
|
}
|
|
|
|
// seedConfig puts the shipped default.yaml where the engine will look for it,
|
|
// and leaves an existing one alone.
|
|
func seedConfig(src, state string) error {
|
|
dst := filepath.Join(state, "config", "default.yaml")
|
|
if _, err := os.Stat(dst); err == nil {
|
|
return nil
|
|
}
|
|
from := filepath.Join(src, "config", "default.yaml")
|
|
b, err := os.ReadFile(from)
|
|
if err != nil {
|
|
return fmt.Errorf("the release is missing config/default.yaml: %w", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(dst, b, 0o644)
|
|
}
|
|
|
|
// copyTree copies a source tree, skipping the caches a checkout accumulates.
|
|
func copyTree(from, to string) error {
|
|
return filepath.WalkDir(from, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(from, path)
|
|
if d.IsDir() {
|
|
if d.Name() == "__pycache__" || strings.HasSuffix(d.Name(), ".egg-info") {
|
|
return filepath.SkipDir
|
|
}
|
|
return os.MkdirAll(filepath.Join(to, rel), 0o755)
|
|
}
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(filepath.Join(to, rel), b, 0o644)
|
|
})
|
|
}
|
|
|
|
// runEngine runs the engine exactly as the app will later: same interpreter,
|
|
// same environment. In particular ChildEnv sets BEHAVISION_DATA_DIR, without
|
|
// which a pip-installed engine decides its state lives in site-packages and
|
|
// downloads the models to a place the app never looks.
|
|
func runEngine(vpy string, args ...string) error {
|
|
full := append([]string{"-m", "behavision"}, args...)
|
|
cmd := exec.Command(vpy, full...)
|
|
cmd.Env = engine.ChildEnv("")
|
|
return stream(cmd, "running the engine")
|
|
}
|
|
|
|
// writeConfig records how to start the engine, in the same file and through
|
|
// the same type the app reads, so the two cannot disagree about it.
|
|
func writeConfig(vpy string) error {
|
|
path := paths.AgentConfig()
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
return fmt.Errorf("reading %s: %w", path, err)
|
|
}
|
|
// An absolute path: the app resolves a relative EngineExe against its own
|
|
// install root under Program Files, and the interpreter is not there.
|
|
cfg.EngineExe = vpy
|
|
cfg.EngineArgs = []string{"-m", "behavision", "run"}
|
|
if cfg.APIBase == "" {
|
|
cfg.APIBase = "http://127.0.0.1:8010"
|
|
}
|
|
return cfg.Save(path)
|
|
}
|
|
|
|
// smokeTest starts the engine exactly as the app will and waits for its API to
|
|
// answer. Any reply counts, including 401: the engine invents its own
|
|
// credential when none is configured, and a refusal proves it is serving.
|
|
func smokeTest(vpy string, demoCams []demo.Camera) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
|
|
cmd.Env = engine.ChildEnv("")
|
|
var log strings.Builder
|
|
cmd.Stdout, cmd.Stderr = &log, &log
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = cmd.Process.Kill()
|
|
_, _ = cmd.Process.Wait()
|
|
}()
|
|
|
|
client := &http.Client{Timeout: 3 * time.Second}
|
|
deadline := time.Now().Add(75 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
resp, err := client.Get("http://127.0.0.1:8010/api/health")
|
|
if err == nil {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
if demoCams == nil {
|
|
return nil
|
|
}
|
|
// Through the engine's own Add Camera, not written to its file:
|
|
// the store is what applies DPAPI to the password on Windows, so
|
|
// this is how the credential ends up encrypted on disk rather
|
|
// than sitting in cameras.json for anyone who can read
|
|
// ProgramData.
|
|
return addCameras(demoCams)
|
|
}
|
|
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
|
|
break
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
return fmt.Errorf("it did not answer within 75 seconds.\n\n%s",
|
|
tail(log.String(), 15))
|
|
}
|
|
|
|
func tail(s string, n int) string {
|
|
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
|
if len(lines) > n {
|
|
lines = lines[len(lines)-n:]
|
|
}
|
|
return " " + strings.Join(lines, "\n ")
|
|
}
|
|
|
|
// stream runs a command and shows its output. Shown, not swallowed: pip failing
|
|
// on a missing build tool prints exactly what is wrong, and hiding that leaves
|
|
// the operator with "setup failed" and nothing to act on.
|
|
func stream(cmd *exec.Cmd, what string) error {
|
|
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("%s failed: %w", what, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pause keeps the window open. Double-clicked from Explorer, a console program
|
|
// that finishes closes instantly and the operator sees nothing at all -
|
|
// success and failure look identical.
|
|
func pause() {
|
|
if runtime.GOOS != "windows" {
|
|
return
|
|
}
|
|
fmt.Print(" Press Enter to close. ")
|
|
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
|
|
}
|
|
|
|
// unlockDemo returns the sealed cameras a demo release ships, or nil when this
|
|
// is not a demo release. Asks for the unlock code on the console; three tries,
|
|
// because a code is read down a phone and typed by hand.
|
|
func unlockDemo(src string) ([]demo.Camera, error) {
|
|
sealed, err := os.ReadFile(filepath.Join(src, "demo-cameras.enc"))
|
|
if err != nil {
|
|
return nil, nil // not a demo release
|
|
}
|
|
fmt.Println()
|
|
fmt.Println(" This is a demo release with the cameras already set up.")
|
|
fmt.Println(" It needs the unlock code you were given.")
|
|
fmt.Println()
|
|
in := bufio.NewReader(os.Stdin)
|
|
for attempt := 1; attempt <= 3; attempt++ {
|
|
fmt.Print(" Unlock code: ")
|
|
line, _ := in.ReadString('\n')
|
|
plain, err := demo.Open(line, sealed)
|
|
if err == nil {
|
|
var cams []demo.Camera
|
|
if err := json.Unmarshal(plain, &cams); err != nil {
|
|
return nil, fmt.Errorf("the bundle unlocked but did not parse: %w", err)
|
|
}
|
|
fmt.Println()
|
|
return cams, nil
|
|
}
|
|
fmt.Printf(" %v\n", err)
|
|
}
|
|
return nil, errors.New("no valid unlock code after three tries. Check it " +
|
|
"with whoever gave you this release and run setup again")
|
|
}
|
|
|
|
// addCameras posts each demo camera to the running engine, with the credential
|
|
// the engine generated for itself on first start.
|
|
func addCameras(cams []demo.Camera) error {
|
|
user, pass, err := engineCredential()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
for _, c := range cams {
|
|
if c.Port == 0 {
|
|
c.Port = 554
|
|
}
|
|
body, _ := json.Marshal(c)
|
|
req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:8010/api/cameras",
|
|
bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if user != "" {
|
|
req.SetBasicAuth(user, pass)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("adding camera %s: %w", c.ID, err)
|
|
}
|
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
resp.Body.Close()
|
|
// 409 is "already there" - a re-run of setup, which is allowed.
|
|
if resp.StatusCode >= 300 && resp.StatusCode != http.StatusConflict {
|
|
return fmt.Errorf("adding camera %s: %s: %s", c.ID, resp.Status,
|
|
strings.TrimSpace(string(msg)))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// engineCredential reads the Basic credential the engine wrote on its first
|
|
// start. Empty when the engine is configured without one.
|
|
func engineCredential() (string, string, error) {
|
|
b, err := os.ReadFile(paths.APICredentials())
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", "", nil
|
|
}
|
|
return "", "", err
|
|
}
|
|
var user, pass string
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
if v, ok := strings.CutPrefix(line, "username="); ok {
|
|
user = strings.TrimSpace(v)
|
|
}
|
|
if v, ok := strings.CutPrefix(line, "password="); ok {
|
|
pass = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return user, pass, nil
|
|
}
|
|
|
|
// markStandalone records that this PC runs on its own, through the same
|
|
// config type the app reads.
|
|
func markStandalone() error {
|
|
path := paths.AgentConfig()
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.Standalone = true
|
|
return cfg.Save(path)
|
|
}
|