Both found by operating the stack rather than writing it: the local processes were OOM-killed and bringing them back hit two gaps. The headless agent had no way to be claimed at all. Bootstrap lived only in desktop/internal/cloud, so the one configuration the agent binary exists for - a back-office PC with no window - could only be onboarded by hand-editing agent.json, which is the state the desktop's Setup screen was built to end. `behavision-agent claim <code>` closes it; the CLI joins its arguments because the code is printed in groups for reading aloud and an operator pasting it will paste the spaces too. Second: after the site's broker password was re-rolled, mosquitto logged "not authorised" while the agent logged "timed out". Those need opposite actions - re-link this PC, or go and look at the network - and paho's SetConnectRetry collapses them, because it retries internally and the connect token never completes. describeStall asks whether a TCP socket opens at all, and says what is known rather than guessing at a reason the broker never gives. Verified end to end: minted a code from the platform as the owner, claimed with the new command, broker connected, and the shop went to online: true with 1/1 cameras on w600k_r50. Also corrects this machine's memory in CLAUDE.md from 16 GB to 8 GB. It feeds the model-fallback reasoning, and the local gallery already holds 17 embeddings tagged w600k_mbf beside 19 tagged w600k_r50 - the fallback has silently fired before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
93 lines
3.1 KiB
Go
93 lines
3.1 KiB
Go
// Package enrol links a PC to a shop, using the one-shot code an operator is
|
|
// given.
|
|
//
|
|
// It existed only inside the desktop app, which meant a HEADLESS install - a
|
|
// back-office PC with no window, the configuration the agent binary is for -
|
|
// could not be claimed at all. The only route was hand-editing agent.json,
|
|
// which is exactly the state the desktop's Setup screen was built to end.
|
|
//
|
|
// The endpoint behind this is deliberately unauthenticated: the PC doing it has
|
|
// nobody signed in yet, and requiring a login would mean shipping a password to
|
|
// every shop that installs the software.
|
|
package enrol
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Bootstrap is what the server hands back: which shop this PC is, and the
|
|
// credentials it needs to say so.
|
|
type Bootstrap struct {
|
|
ClientSlug string `json:"client_slug"`
|
|
SiteSlug string `json:"site_slug"`
|
|
SiteName string `json:"site_name"`
|
|
MQTTURL string `json:"mqtt_url"`
|
|
MQTTUser string `json:"mqtt_username"`
|
|
MQTTPass string `json:"mqtt_password"`
|
|
CAPem string `json:"ca_pem,omitempty"`
|
|
AgentToken string `json:"agent_token"`
|
|
}
|
|
|
|
// Claim redeems an installation code.
|
|
//
|
|
// The code is read aloud down a phone and photographed off screens, so what is
|
|
// typed here can be as untidy as it needs to be: the server strips spaces,
|
|
// dashes and case at its end. Sending it as typed keeps ONE implementation of
|
|
// that normalisation, on the side that also issued the code - two would
|
|
// eventually disagree and hash to something the redeemer never produces.
|
|
func Claim(ctx context.Context, base, code string) (Bootstrap, error) {
|
|
var out Bootstrap
|
|
base = strings.TrimRight(base, "/")
|
|
if base == "" {
|
|
return out, fmt.Errorf("no server address configured (set cloud_base or BEHAVISION_CLOUD)")
|
|
}
|
|
body, err := json.Marshal(map[string]string{"site_token": code})
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
base+"/api/agent/enrol", bytes.NewReader(body))
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return out, fmt.Errorf("could not reach %s: %w", base, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
blob, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
|
if resp.StatusCode != http.StatusOK {
|
|
// The server answers unknown, expired and already-used identically on
|
|
// purpose - the difference only helps somebody guessing codes, and the
|
|
// operator's next step is the same in all three cases. Its own words
|
|
// are passed through rather than reworded here.
|
|
var e struct {
|
|
Message string `json:"message"`
|
|
}
|
|
_ = json.Unmarshal(blob, &e)
|
|
if e.Message != "" {
|
|
return out, fmt.Errorf("%s", e.Message)
|
|
}
|
|
return out, fmt.Errorf("head office: %s", resp.Status)
|
|
}
|
|
if err := json.Unmarshal(blob, &out); err != nil {
|
|
return out, err
|
|
}
|
|
if out.SiteSlug == "" || out.MQTTURL == "" {
|
|
return out, fmt.Errorf("head office returned an incomplete setup")
|
|
}
|
|
return out, nil
|
|
}
|