Compare commits
5 Commits
92b12bcb1c
...
v0.4.0-dem
| Author | SHA1 | Date | |
|---|---|---|---|
| b59e667a68 | |||
| 70c447873d | |||
| 719ba2c7f5 | |||
| 5e1dcf7050 | |||
| 92573e9067 |
80
agent/cmd/behavision-demo-pack/main.go
Normal file
80
agent/cmd/behavision-demo-pack/main.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Command behavision-demo-pack seals a camera list into demo-cameras.enc for a
|
||||
// demo release. It runs on the machine that builds the release and is never
|
||||
// shipped.
|
||||
//
|
||||
// behavision-demo-pack -cameras cameras.json -out demo-cameras.enc
|
||||
//
|
||||
// Prints the unlock code exactly once. It is not stored anywhere; a code you
|
||||
// can look up later is a code anyone with access to the build machine holds.
|
||||
// Lose it and seal again.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/demo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
in := flag.String("cameras", "", "JSON array of cameras (id, host, port, path, username, password)")
|
||||
out := flag.String("out", "demo-cameras.enc", "sealed bundle to write")
|
||||
flag.Parse()
|
||||
if *in == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: behavision-demo-pack -cameras cameras.json [-out demo-cameras.enc]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(*in)
|
||||
if err != nil {
|
||||
die("read cameras: %v", err)
|
||||
}
|
||||
var cams []demo.Camera
|
||||
if err := json.Unmarshal(raw, &cams); err != nil {
|
||||
die("cameras.json: %v", err)
|
||||
}
|
||||
if len(cams) == 0 {
|
||||
die("no cameras in %s", *in)
|
||||
}
|
||||
for i, c := range cams {
|
||||
switch {
|
||||
case c.ID == "":
|
||||
die("camera %d has no id", i)
|
||||
case c.Host == "":
|
||||
die("camera %q has no host", c.ID)
|
||||
case c.Path == "":
|
||||
die("camera %q has no path - the stream path is the field nobody can guess", c.ID)
|
||||
}
|
||||
}
|
||||
// Re-marshal so only the fields the engine accepts travel, in a stable
|
||||
// shape, whatever extra keys the input happened to carry.
|
||||
plain, err := json.Marshal(cams)
|
||||
if err != nil {
|
||||
die("marshal: %v", err)
|
||||
}
|
||||
|
||||
code, err := demo.NewCode()
|
||||
if err != nil {
|
||||
die("code: %v", err)
|
||||
}
|
||||
sealed, err := demo.Seal(code, plain)
|
||||
if err != nil {
|
||||
die("seal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*out, sealed, 0o644); err != nil {
|
||||
die("write: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n sealed %d camera(s) into %s (%d bytes)\n\n", len(cams), *out, len(sealed))
|
||||
fmt.Printf(" unlock code: %s\n\n", code)
|
||||
fmt.Println(" Shown once. Give it to whoever runs behavision-setup, by voice")
|
||||
fmt.Println(" or message - not in the same place as the zip.")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func die(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, " "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -32,7 +32,12 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -68,6 +73,17 @@ func run() error {
|
||||
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
|
||||
@@ -81,6 +97,17 @@ func run() error {
|
||||
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 {
|
||||
@@ -101,10 +128,19 @@ func run() error {
|
||||
// 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); err != nil {
|
||||
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.")
|
||||
@@ -237,13 +273,81 @@ func pipInstall(vpy, src string) error {
|
||||
"pip", "setuptools", "wheel"), "updating pip"); err != nil {
|
||||
return err
|
||||
}
|
||||
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", src),
|
||||
|
||||
// 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...)
|
||||
return stream(exec.Command(vpy, full...), "running the engine")
|
||||
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
|
||||
@@ -267,11 +371,12 @@ func writeConfig(vpy string) error {
|
||||
// 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) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
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 {
|
||||
@@ -289,8 +394,16 @@ func smokeTest(vpy string) error {
|
||||
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
|
||||
}
|
||||
@@ -329,3 +442,102 @@ func pause() {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ func cmdRun() error {
|
||||
// nothing - the URL was returned, logged and even exposed on the
|
||||
// desktop's status object, and never actually given to the engine.
|
||||
// A claimed shop PC published heartbeats and zero visits.
|
||||
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+hookURL)
|
||||
cmd.Env = engine.ChildEnv(hookURL)
|
||||
return cmd
|
||||
},
|
||||
LogWriter: logFile,
|
||||
|
||||
114
agent/pkg/demo/bundle.go
Normal file
114
agent/pkg/demo/bundle.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Package demo seals a camera list so a release can carry it without carrying
|
||||
// the credentials in any usable form.
|
||||
//
|
||||
// The need: a demo build that installs with the office cameras already set up,
|
||||
// handed to people who should not be able to read the cameras' admin password
|
||||
// out of the zip. "Encode it" does not do that - anything the installer can
|
||||
// decode, anyone holding the installer can decode. So the bundle is encrypted
|
||||
// with a key that is NOT in the package: a short unlock code, generated when
|
||||
// the bundle is sealed, spoken or messaged to whoever runs setup, and typed
|
||||
// once. Without it the file is noise.
|
||||
//
|
||||
// The code is random, not chosen, so it is used as key material directly
|
||||
// (through SHA-256) rather than stretched with a KDF. A human-chosen
|
||||
// passphrase would need argon2 and a dependency; 120 random bits do not.
|
||||
package demo
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Magic identifies the file and the format version, so a future change can be
|
||||
// told apart from corruption instead of failing as "authentication failed".
|
||||
const magic = "BVDEMO1\n"
|
||||
|
||||
// Camera is one entry as the engine's Add Camera endpoint accepts it.
|
||||
type Camera struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Path string `json:"path"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
MaxWidth int `json:"max_width,omitempty"`
|
||||
}
|
||||
|
||||
// NewCode mints an unlock code: 15 random bytes as 24 base32 characters in
|
||||
// four groups, the same shape as an installation code, for the same reason -
|
||||
// it gets read down a phone.
|
||||
func NewCode() (string, error) {
|
||||
raw := make([]byte, 15)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)
|
||||
return fmt.Sprintf("%s-%s-%s-%s", s[0:6], s[6:12], s[12:18], s[18:24]), nil
|
||||
}
|
||||
|
||||
// NormalizeCode makes the typed and the printed form hash the same: case,
|
||||
// spaces and dashes are all noise a person adds or drops.
|
||||
func NormalizeCode(code string) string {
|
||||
code = strings.ToUpper(code)
|
||||
code = strings.NewReplacer("-", "", " ", "", "\t", "", "\r", "", "\n", "").Replace(code)
|
||||
return code
|
||||
}
|
||||
|
||||
func keyFor(code string) []byte {
|
||||
sum := sha256.Sum256([]byte("behavision-demo-bundle:" + NormalizeCode(code)))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// Seal encrypts plaintext under the code. Output is magic || nonce || ciphertext.
|
||||
func Seal(code string, plaintext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(keyFor(code))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := append([]byte(magic), nonce...)
|
||||
return gcm.Seal(out, nonce, plaintext, []byte(magic)), nil
|
||||
}
|
||||
|
||||
// ErrWrongCode is what a mistyped code looks like. GCM cannot tell a wrong key
|
||||
// from a corrupted file, and neither can we, so both read as this.
|
||||
var ErrWrongCode = errors.New("that unlock code does not open this bundle")
|
||||
|
||||
// Open decrypts a sealed bundle.
|
||||
func Open(code string, sealed []byte) ([]byte, error) {
|
||||
if !strings.HasPrefix(string(sealed), magic) {
|
||||
return nil, errors.New("not a Behavision demo bundle")
|
||||
}
|
||||
body := sealed[len(magic):]
|
||||
block, err := aes.NewCipher(keyFor(code))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) < gcm.NonceSize() {
|
||||
return nil, errors.New("bundle is truncated")
|
||||
}
|
||||
nonce, ct := body[:gcm.NonceSize()], body[gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ct, []byte(magic))
|
||||
if err != nil {
|
||||
return nil, ErrWrongCode
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
87
agent/pkg/demo/bundle_test.go
Normal file
87
agent/pkg/demo/bundle_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package demo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSealedBundleRoundTripsWithTheCodeAsTyped(t *testing.T) {
|
||||
code, err := NewCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(NormalizeCode(code)) != 24 {
|
||||
t.Fatalf("code should be 24 base32 chars, got %q", code)
|
||||
}
|
||||
secret := []byte(`[{"id":"cam1","password":"the-camera-admin-password"}]`)
|
||||
|
||||
sealed, err := Seal(code, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// People type codes in lower case, with the dashes dropped, with a space
|
||||
// where a dash was. All of those are the same code.
|
||||
for _, typed := range []string{
|
||||
code,
|
||||
strings.ToLower(code),
|
||||
strings.ReplaceAll(code, "-", ""),
|
||||
strings.ReplaceAll(code, "-", " "),
|
||||
" " + code + "\n",
|
||||
} {
|
||||
got, err := Open(typed, sealed)
|
||||
if err != nil {
|
||||
t.Fatalf("open with %q: %v", typed, err)
|
||||
}
|
||||
if !bytes.Equal(got, secret) {
|
||||
t.Fatalf("round trip changed the contents")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the file: the password is not in it.
|
||||
func TestTheSealedFileDoesNotContainTheSecret(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
sealed, _ := Seal(code, []byte(`{"password":"the-camera-admin-password","host":"192.168.1.121"}`))
|
||||
for _, leak := range []string{"the-camera-admin-password", "192.168.1.121", "password"} {
|
||||
if bytes.Contains(sealed, []byte(leak)) {
|
||||
t.Fatalf("sealed bundle contains %q in the clear", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrongCodeIsRefusedNotMisread(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
other, _ := NewCode()
|
||||
sealed, _ := Seal(code, []byte("secret"))
|
||||
|
||||
if _, err := Open(other, sealed); !errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a different code should be ErrWrongCode, got %v", err)
|
||||
}
|
||||
// One flipped byte in the ciphertext is the same answer: GCM refuses
|
||||
// rather than returning garbage that then gets written into cameras.json.
|
||||
tampered := append([]byte{}, sealed...)
|
||||
tampered[len(tampered)-1] ^= 0x01
|
||||
if _, err := Open(code, tampered); !errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a tampered bundle should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSomethingThatIsNotABundleSaysSo(t *testing.T) {
|
||||
if _, err := Open("ABCDEF-GHIJKL-MNOPQR-STUVWX", []byte("hello")); err == nil ||
|
||||
errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a non-bundle should be named as such, not blamed on the code: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two seals of the same plaintext under the same code must differ: a fixed
|
||||
// nonce would let two releases' bundles be compared byte for byte.
|
||||
func TestEverySealIsDifferent(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
a, _ := Seal(code, []byte("same"))
|
||||
b, _ := Seal(code, []byte("same"))
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("nonce is not random")
|
||||
}
|
||||
}
|
||||
41
agent/pkg/engine/env.go
Normal file
41
agent/pkg/engine/env.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// ChildEnv is the environment the engine is launched with, wherever it is
|
||||
// launched from - the desktop app and the headless agent both go through
|
||||
// here, so a third caller cannot get it half right.
|
||||
//
|
||||
// The line that matters is BEHAVISION_DATA_DIR.
|
||||
//
|
||||
// The engine's paths.py knows two worlds: frozen with PyInstaller, where state
|
||||
// lives under ProgramData, and a checkout, where everything sits in the repo
|
||||
// root. An engine installed from source into a virtual environment is neither.
|
||||
// Left to itself it resolves its state root to site-packages - writes its
|
||||
// database and camera list there, and generates its API credential into a
|
||||
// folder this process never reads - while this process resolves the same
|
||||
// state root to ProgramData. The two halves then disagree about where
|
||||
// everything lives, and every call to the engine is 401 on a stock install,
|
||||
// with nothing in either log saying why. Seen twice: once on a Mac checkout
|
||||
// (the app in ~/Library, the engine in the repo) and once in a clean Linux
|
||||
// container running the installer.
|
||||
//
|
||||
// Telling the engine where THIS process keeps state makes the two agree by
|
||||
// construction, however the engine was installed. paths.py honours the
|
||||
// override ahead of every other rule it has.
|
||||
//
|
||||
// hookURL is where the engine posts detections; empty is allowed and means
|
||||
// the bridge has not started, which the engine treats as "no webhook".
|
||||
func ChildEnv(hookURL string) []string {
|
||||
env := append(os.Environ(),
|
||||
"BEHAVISION_DATA_DIR="+paths.StateRoot(),
|
||||
)
|
||||
if hookURL != "" {
|
||||
env = append(env, "BEHAVISION_WEBHOOK_URL="+hookURL)
|
||||
}
|
||||
return env
|
||||
}
|
||||
44
agent/pkg/engine/env_test.go
Normal file
44
agent/pkg/engine/env_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// The engine must be told where THIS process keeps state, or a pip-installed
|
||||
// engine decides on site-packages and the two halves never find each other.
|
||||
func TestTheEngineIsToldWhereStateLives(t *testing.T) {
|
||||
t.Setenv("BEHAVISION_DATA_DIR", t.TempDir())
|
||||
|
||||
env := ChildEnv("http://127.0.0.1:5555/events")
|
||||
|
||||
want := "BEHAVISION_DATA_DIR=" + paths.StateRoot()
|
||||
if !contains(env, want) {
|
||||
t.Fatalf("engine env lacks %q - a source-installed engine would put its "+
|
||||
"database and credential somewhere this process never looks", want)
|
||||
}
|
||||
if !contains(env, "BEHAVISION_WEBHOOK_URL=http://127.0.0.1:5555/events") {
|
||||
t.Fatal("webhook url not passed to the engine")
|
||||
}
|
||||
}
|
||||
|
||||
// Before the bridge has a port there is no webhook. An empty variable would be
|
||||
// read by the engine as a webhook at "", which is not the same as none.
|
||||
func TestNoWebhookMeansNoVariable(t *testing.T) {
|
||||
for _, v := range ChildEnv("") {
|
||||
if strings.HasPrefix(v, "BEHAVISION_WEBHOOK_URL=") {
|
||||
t.Fatalf("empty hook still exported: %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(env []string, want string) bool {
|
||||
for _, v := range env {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
// be told again. Without it the engine recognised people and the
|
||||
// bridge received nothing: a claimed shop PC published heartbeats
|
||||
// and zero visits.
|
||||
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+a.webhookURL())
|
||||
cmd.Env = agentengine.ChildEnv(a.webhookURL())
|
||||
return cmd
|
||||
},
|
||||
LogWriter: logFile,
|
||||
@@ -124,6 +124,23 @@ func (a *App) startup(ctx context.Context) {
|
||||
})
|
||||
|
||||
a.startPipeline(ctx)
|
||||
|
||||
// Recognition starts with the app. Until this, the engine only ever
|
||||
// started when somebody pressed Start - which meant a till that rebooted
|
||||
// overnight came back with the window open, the tray icon showing, the
|
||||
// session restored, and recognition off until a shop assistant noticed.
|
||||
// That is the failure the tray colours exist to catch, and it should not
|
||||
// be the default state every morning.
|
||||
//
|
||||
// Guarded on the interpreter actually being there: on a PC where setup has
|
||||
// not run yet, starting the supervisor would loop on a missing executable
|
||||
// with nothing useful to say. The Start button still exists for the one
|
||||
// case where somebody has deliberately stopped it.
|
||||
if _, err := os.Stat(exe); err == nil {
|
||||
a.sup.Start()
|
||||
} else {
|
||||
log.Printf("engine not installed yet (%s); run behavision-setup, then Start", exe)
|
||||
}
|
||||
}
|
||||
|
||||
// webhookURL is the loopback address the bridge is listening on, or empty
|
||||
|
||||
@@ -180,9 +180,16 @@ func (c *Client) send(ctx context.Context, method, path string, raw []byte, out
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
|
||||
return errTokenExpired
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
case resp.StatusCode == http.StatusUnauthorized && tok != "":
|
||||
// A 401 on a call we sent a session with: the session is the problem.
|
||||
return ErrUnauthorized
|
||||
case resp.StatusCode >= 400:
|
||||
// Every other 4xx/5xx - including a 401 on a call that carried NO
|
||||
// session, such as redeeming an installation code - is about the
|
||||
// request, and the server wrote its message for exactly this moment.
|
||||
// Mapping those to "session expired" told an installer their session
|
||||
// had lapsed on a screen where they had never signed in, and hid
|
||||
// "That installation code is not valid" behind it.
|
||||
msg := e.Message
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -137,3 +138,43 @@ func TestVisitorIDIsPathEscaped(t *testing.T) {
|
||||
t.Errorf("path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Redeeming an installation code is the one call a fresh PC makes before it
|
||||
// has any session. When the server refuses it - wrong code, wrong head office -
|
||||
// it answers 401 with a message written for the installer. That message must
|
||||
// reach them: "session expired" on a screen where nobody has signed in sent a
|
||||
// real installer looking for a login problem that did not exist.
|
||||
func TestARefusedInstallationCodeSaysWhyNotSessionExpired(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Errorf("enrol must not carry a session, got %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
fail(w, http.StatusUnauthorized, "bad_token",
|
||||
"That installation code is not valid. Ask for a new one.")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := New(srv.URL) // deliberately no session
|
||||
|
||||
_, err := c.Bootstrap(context.Background(), "KWFH5S-EH46LT-EE4X47-OSOH7D")
|
||||
if err == nil {
|
||||
t.Fatal("a refused code must be an error")
|
||||
}
|
||||
if errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("a refused code is not a session problem, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "installation code is not valid") {
|
||||
t.Fatalf("the server's own words should reach the installer, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The other side of the same rule: a 401 on a call that DID carry a session is
|
||||
// a session problem, and must still read as one.
|
||||
func TestARejectedSessionStillReadsAsSessionExpired(t *testing.T) {
|
||||
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusUnauthorized, "unauthorized", "Sign in again.")
|
||||
})
|
||||
err := c.do(context.Background(), http.MethodGet, "/api/auth/me", nil, nil)
|
||||
if !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("a 401 with a session should be ErrUnauthorized, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
120
installer/INSTALL.txt
Normal file
120
installer/INSTALL.txt
Normal file
@@ -0,0 +1,120 @@
|
||||
Behavision — installing on a shop PC
|
||||
====================================
|
||||
|
||||
This is a source install. It needs Python and a working internet connection
|
||||
once, at setup. After that the shop PC runs on its own.
|
||||
|
||||
|
||||
WHAT YOU NEED FIRST
|
||||
-------------------
|
||||
|
||||
Python 3.10 or newer.
|
||||
|
||||
https://www.python.org/downloads/windows/
|
||||
|
||||
On the very first screen of the Python installer, tick
|
||||
"Add python.exe to PATH". If you miss it, setup cannot find Python and
|
||||
you will have to run the Python installer again.
|
||||
|
||||
|
||||
SETTING UP
|
||||
----------
|
||||
|
||||
1. Unzip this whole folder somewhere permanent — for example
|
||||
C:\Behavision. Keep the files together; behavision-setup.exe looks for
|
||||
the engine-src folder next to itself.
|
||||
|
||||
2. Double-click behavision-setup.exe
|
||||
|
||||
It will:
|
||||
- find your Python and check it is new enough
|
||||
- build a private Python environment under
|
||||
C:\ProgramData\Behavision\runtime
|
||||
- install the recognition engine and its libraries (from the wheel
|
||||
in engine-src; the folder you unzipped is never written to)
|
||||
- download the recognition models (a few hundred megabytes)
|
||||
- start the engine once to prove it works
|
||||
|
||||
This takes several minutes. Leave the window open until it says Done.
|
||||
If anything fails it prints why, and running it again is safe.
|
||||
|
||||
DEMO RELEASE ONLY: if the release came with the cameras already set up,
|
||||
setup first asks for an unlock code. Type the code you were given. The
|
||||
camera details are sealed inside the release and cannot be read without
|
||||
it; with it, both cameras are added and the PC is set to run on its own,
|
||||
with no head office. Skip the installation-code screen - it will not
|
||||
appear.
|
||||
|
||||
3. Double-click Behavision.exe
|
||||
|
||||
The window opens and an icon appears in the system tray, next to the
|
||||
clock. Right-click the tray icon to open the window again, or to stop
|
||||
recognition.
|
||||
|
||||
|
||||
CONNECTING IT TO HEAD OFFICE
|
||||
----------------------------
|
||||
|
||||
The first screen asks for an installation code. Ask whoever manages your
|
||||
shops — they create one from the Behavision platform, under the shop.
|
||||
|
||||
No head office? Choose "set this PC up on its own" on the same screen.
|
||||
Recognition, the cameras and the customer list all work locally; nothing is
|
||||
sent anywhere.
|
||||
|
||||
|
||||
ADDING A CAMERA
|
||||
---------------
|
||||
|
||||
Cameras → Add. You need the camera's address on the shop network, its
|
||||
username and password. Choose your camera's make from the list and the
|
||||
stream path is filled in for you — that is the field nobody can look up.
|
||||
|
||||
Press "Test" before saving. Then press "Check placement" and walk past the
|
||||
camera a few times. It will tell you whether the camera can actually
|
||||
recognise faces from where it is mounted, which is not the same question as
|
||||
whether it is connected.
|
||||
|
||||
Camera placement matters more than camera quality. Aim for roughly head
|
||||
height, facing the direction people walk in. A camera high in a corner
|
||||
looking down, or pointing at a bright window or glass door, will connect
|
||||
perfectly and recognise almost nobody.
|
||||
|
||||
|
||||
WHERE THINGS LIVE
|
||||
-----------------
|
||||
|
||||
C:\ProgramData\Behavision\ database, logs, camera list, models
|
||||
C:\ProgramData\Behavision\runtime the engine's own Python
|
||||
|
||||
Everything the software writes is under ProgramData. The folder you unzipped
|
||||
is never written to, so you can keep it on a shared drive.
|
||||
|
||||
|
||||
STOPPING IT
|
||||
-----------
|
||||
|
||||
Right-click the tray icon and choose Quit. That stops recognition as well —
|
||||
leaving it running with no visible control would be worse than stopping it.
|
||||
|
||||
Closing the window does NOT stop recognition. The window hides and the tray
|
||||
icon stays, because a shop assistant clicking X should not switch the shop's
|
||||
footfall counting off for the rest of the day.
|
||||
|
||||
|
||||
IF SOMETHING IS WRONG
|
||||
---------------------
|
||||
|
||||
"No Python 3.10 or newer was found"
|
||||
Python is missing, too old, or was installed without the
|
||||
"Add python.exe to PATH" tick. Reinstall Python with that ticked.
|
||||
|
||||
Setup fails while installing libraries
|
||||
Almost always no internet, or a proxy in the way. The error printed
|
||||
just above the failure says which.
|
||||
|
||||
The window opens but says the engine is not running
|
||||
Run behavision-setup.exe again; it will report what is missing.
|
||||
|
||||
Logs
|
||||
C:\ProgramData\Behavision\engine.log
|
||||
21
installer/run-with-lan-head-office.cmd
Normal file
21
installer/run-with-lan-head-office.cmd
Normal file
@@ -0,0 +1,21 @@
|
||||
@echo off
|
||||
rem Start Behavision against a head office running on another PC on this LAN,
|
||||
rem instead of the production server it uses by default.
|
||||
rem
|
||||
rem For demos and pilots only. Two things are deliberately weaker than
|
||||
rem production and both are named here so nobody copies this into a shop:
|
||||
rem
|
||||
rem - head office over plain http, not https
|
||||
rem - the message broker over plain tcp. The app REFUSES plaintext MQTT to
|
||||
rem any address that is not its own machine, by design - the payloads are
|
||||
rem customer visit records - so the second line below is the documented
|
||||
rem escape hatch and must not be set anywhere that is not a demo.
|
||||
rem
|
||||
rem Edit the address to the PC running head office, then double-click this
|
||||
rem instead of Behavision.exe. Everything else - the installation code, the
|
||||
rem sign-in, the cameras - works exactly as INSTALL.txt describes.
|
||||
|
||||
set BEHAVISION_CLOUD=http://192.168.1.117:8088
|
||||
set BEHAVISION_ALLOW_PLAINTEXT_MQTT=1
|
||||
|
||||
start "" "%~dp0Behavision.exe"
|
||||
@@ -14,6 +14,10 @@ dependencies = [
|
||||
"python-dotenv>=1.0",
|
||||
"faiss-cpu>=1.7.4",
|
||||
"requests>=2.31",
|
||||
# DPAPI for camera passwords at rest (behavision/cameras.py). Without it the
|
||||
# store logs a warning and writes them in the clear - which is what every
|
||||
# Windows install had been doing, since nothing pulled this in.
|
||||
"pywin32>=306; sys_platform == 'win32'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -8,3 +8,4 @@ PyYAML>=6.0
|
||||
python-dotenv>=1.0
|
||||
faiss-cpu>=1.7.4
|
||||
requests>=2.31
|
||||
pywin32>=306; sys_platform == "win32"
|
||||
|
||||
Reference in New Issue
Block a user