A headless PC can be claimed, and a refused broker says so
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
This commit is contained in:
92
agent/pkg/enrol/enrol.go
Normal file
92
agent/pkg/enrol/enrol.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// 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
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -104,7 +106,13 @@ func NewClient(opts ClientOptions) (*Client, error) {
|
||||
|
||||
tok := c.client.Connect()
|
||||
if !tok.WaitTimeout(20 * time.Second) {
|
||||
return c, fmt.Errorf("mqtt: connect to %s timed out", opts.BrokerURL)
|
||||
// SetConnectRetry means paho retries internally and this token never
|
||||
// completes, so a REFUSED connection and an UNREACHABLE broker both
|
||||
// arrive here as a timeout. They need opposite actions - re-link this
|
||||
// PC, or go and look at the network - and reporting both as "timed
|
||||
// out" sent the diagnosis to the wrong place. Measured: mosquitto
|
||||
// logged "not authorised" while the agent logged a timeout.
|
||||
return c, fmt.Errorf("mqtt: %s", describeStall(opts.BrokerURL))
|
||||
}
|
||||
if err := tok.Error(); err != nil {
|
||||
return c, fmt.Errorf("mqtt: connect to %s: %w", opts.BrokerURL, err)
|
||||
@@ -112,6 +120,47 @@ func NewClient(opts ClientOptions) (*Client, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// describeStall says which of the two failures this is, by asking the one
|
||||
// question that separates them: can we open a socket to the broker at all?
|
||||
//
|
||||
// It cannot name the exact reason - the broker does not tell a rejected client
|
||||
// why, and a TLS failure looks the same from here - so it says what is known
|
||||
// and what to check, rather than guessing. Being reachable but not accepted is
|
||||
// overwhelmingly a credential this PC no longer has, which is what happens when
|
||||
// a site is re-provisioned.
|
||||
func describeStall(brokerURL string) string {
|
||||
host := brokerHostPort(brokerURL)
|
||||
if host == "" {
|
||||
return fmt.Sprintf("connect to %s timed out", brokerURL)
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", host, 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("cannot reach the broker at %s: %v - check the "+
|
||||
"network and that the broker is running", host, err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
return fmt.Sprintf("the broker at %s is reachable but did not accept this "+
|
||||
"PC - usually its credentials are no longer valid; re-link it with "+
|
||||
"`behavision-agent claim <code>`", host)
|
||||
}
|
||||
|
||||
// brokerHostPort extracts host:port for the reachability probe. Parsed with
|
||||
// net/url, never by scanning for the first ":" - an IPv6 literal is bracketed
|
||||
// and full of them.
|
||||
func brokerHostPort(brokerURL string) string {
|
||||
u, err := url.Parse(brokerURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
if u.Port() != "" {
|
||||
return u.Host
|
||||
}
|
||||
if strings.HasPrefix(brokerURL, "tls://") || strings.HasPrefix(brokerURL, "ssl://") {
|
||||
return net.JoinHostPort(u.Hostname(), "8883")
|
||||
}
|
||||
return net.JoinHostPort(u.Hostname(), "1883")
|
||||
}
|
||||
|
||||
// Publish sends one message at QoS 1 and waits for the broker's PUBACK.
|
||||
//
|
||||
// QoS 1, not 0 or 2. At QoS 0 the broker never confirms, so the pump would ack
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -102,3 +103,66 @@ func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
|
||||
func writeFile(path, content string) error {
|
||||
return osWriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
|
||||
// "The broker refused this PC" and "the broker is not there" need opposite
|
||||
// actions - re-link this PC, or go and look at the network - and paho's
|
||||
// connect-retry makes both arrive as a timeout. Measured on a real broker:
|
||||
// mosquitto logged "not authorised" while the agent logged a timeout, which
|
||||
// sent the diagnosis to the wrong place.
|
||||
func TestARefusedBrokerIsNotDescribedAsUnreachable(t *testing.T) {
|
||||
// A listener that accepts TCP and then says nothing is exactly what a
|
||||
// broker rejecting a client looks like from out here.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = c
|
||||
}
|
||||
}()
|
||||
|
||||
got := describeStall("tcp://" + ln.Addr().String())
|
||||
if !strings.Contains(got, "reachable but did not accept") {
|
||||
t.Fatalf("a reachable broker was described as unreachable: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "claim") {
|
||||
t.Errorf("the message does not say what to do about it: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAbsentBrokerIsDescribedAsUnreachable(t *testing.T) {
|
||||
// Bound and immediately closed, so the port is certainly nobody's.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
|
||||
got := describeStall("tcp://" + addr)
|
||||
if !strings.Contains(got, "cannot reach the broker") {
|
||||
t.Fatalf("an absent broker was not described as unreachable: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An IPv6 literal is bracketed and full of colons, so scanning for the first
|
||||
// one gives "[". The same bug this package already fixed once for broker URLs.
|
||||
func TestTheProbeAddressHandlesIPv6AndDefaultPorts(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"tcp://127.0.0.1:51883", "127.0.0.1:51883"},
|
||||
{"tcp://[::1]:1883", "[::1]:1883"},
|
||||
{"tcp://broker.example", "broker.example:1883"},
|
||||
{"tls://broker.example", "broker.example:8883"},
|
||||
{"tls://[2001:db8::1]:8884", "[2001:db8::1]:8884"},
|
||||
} {
|
||||
if got := brokerHostPort(tc.in); got != tc.want {
|
||||
t.Errorf("brokerHostPort(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user