From 5f83a1077d0aac7cabcf103bd5a2e039a7d64b4e Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Fri, 18 Sep 2026 12:16:43 +0530 Subject: [PATCH] Enrolment hands out the broker CA, and now the PC keeps it The server has always sent the broker's CA certificate in the enrolment response, precisely so it never has to ship in an installer. Nothing on the receiving end wrote it anywhere: the agent read the field under the wrong name (ca_pem, the server says ca_cert) and the desktop app read it correctly and dropped it. Every claimed PC therefore dialled tls://mcp.loyaly.ai:8883 with the system trust store, the private CA failed verification, and the agent reported 'the broker did not accept this PC' - a TLS failure is indistinguishable from a refusal at that layer. No real site could ever have published a visit. Found by claiming this Mac as a real shop against production; fixed by writing the CA to broker-ca.crt beside agent.json on both claim paths. Verified: broker connected over TLS, camera pushed from head office, engine streaming it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj --- .gitignore | 1 + agent/main.go | 5 +++++ agent/pkg/enrol/enrol.go | 23 ++++++++++++++++++++++- agent/pkg/paths/paths.go | 7 +++++-- desktop/app.go | 6 ++++++ 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index ef415a7..36ea1ed 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ node_modules/ # Left behind by `pip install .` of the engine (setuptools metadata), not source. /behavision.egg-info/ +/.prod/ diff --git a/agent/main.go b/agent/main.go index d1575c1..8c55b98 100644 --- a/agent/main.go +++ b/agent/main.go @@ -119,6 +119,11 @@ func cmdClaim(args []string) error { cfg.BrokerPassword = b.MQTTPass cfg.AgentToken = b.AgentToken cfg.CloudBase = base + caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA()) + if err != nil { + return err + } + cfg.BrokerCAFile = caPath // A PC that was running on its own and has now been linked is no longer // standalone. cfg.Standalone = false diff --git a/agent/pkg/enrol/enrol.go b/agent/pkg/enrol/enrol.go index 4d944e6..7ee7bff 100644 --- a/agent/pkg/enrol/enrol.go +++ b/agent/pkg/enrol/enrol.go @@ -18,6 +18,7 @@ import ( "fmt" "io" "net/http" + "os" "strings" "time" ) @@ -31,7 +32,7 @@ type Bootstrap struct { MQTTURL string `json:"mqtt_url"` MQTTUser string `json:"mqtt_username"` MQTTPass string `json:"mqtt_password"` - CAPem string `json:"ca_pem,omitempty"` + CACert string `json:"ca_cert,omitempty"` AgentToken string `json:"agent_token"` } @@ -90,3 +91,23 @@ func Claim(ctx context.Context, base, code string) (Bootstrap, error) { } return out, nil } + +// SaveCA writes the broker's CA beside the agent config and returns its path. +// +// The server hands the CA out at enrolment precisely so it never has to be +// shipped in an installer - and for a while nothing on the receiving end +// wrote it anywhere. Every claimed PC then dialled tls://mcp.loyaly.ai:8883 +// with the system trust store, the private CA failed verification, and the +// agent reported "the broker did not accept this PC" (a TLS failure is +// indistinguishable from a refusal at that layer). No real site could ever +// publish a visit. An empty CA returns "" so a deployment on a public +// certificate keeps working unchanged. +func SaveCA(pem, path string) (string, error) { + if strings.TrimSpace(pem) == "" { + return "", nil + } + if err := os.WriteFile(path, []byte(pem), 0o600); err != nil { + return "", fmt.Errorf("write broker CA: %w", err) + } + return path, nil +} diff --git a/agent/pkg/paths/paths.go b/agent/pkg/paths/paths.go index cbdcc6a..1eef4ac 100644 --- a/agent/pkg/paths/paths.go +++ b/agent/pkg/paths/paths.go @@ -57,8 +57,11 @@ func InstallRoot() string { } func AgentConfig() string { return filepath.Join(StateRoot(), "agent.json") } -func SpoolDir() string { return filepath.Join(StateRoot(), "spool") } -func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") } + +// BrokerCA is the broker's CA certificate, written at enrolment. +func BrokerCA() string { return filepath.Join(StateRoot(), "broker-ca.crt") } +func SpoolDir() string { return filepath.Join(StateRoot(), "spool") } +func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") } // APICredentials is the file the engine writes when it generates its own // Basic credentials. The agent reads it rather than storing a second copy, diff --git a/desktop/app.go b/desktop/app.go index 4bf36f1..df52b3a 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -23,6 +23,7 @@ import ( agentcameras "github.com/loyaly/behavision-agent/pkg/cameras" agentcfg "github.com/loyaly/behavision-agent/pkg/config" agentengine "github.com/loyaly/behavision-agent/pkg/engine" + "github.com/loyaly/behavision-agent/pkg/enrol" agentmqtt "github.com/loyaly/behavision-agent/pkg/mqtt" agentpaths "github.com/loyaly/behavision-agent/pkg/paths" agentspool "github.com/loyaly/behavision-agent/pkg/spool" @@ -434,6 +435,11 @@ func (a *App) Claim(code string) (SessionInfo, error) { a.cfg.BrokerPassword = b.MQTTPass a.cfg.AgentToken = b.AgentToken a.cfg.CloudBase = a.cloud.Base + caPath, err := enrol.SaveCA(b.CACert, agentpaths.BrokerCA()) + if err != nil { + return SessionInfo{}, err + } + a.cfg.BrokerCAFile = caPath // A PC that was running on its own and has now been linked is no longer // standalone. Leaving the flag set would keep the head-office screens // hidden on the one machine that just earned them.