Files
Behavision/agent/pkg/mqtt/client.go
Suriyakumarvijayanayagam 50d122e5f0 An unused constant was a Stop() that could hang forever
Ran staticcheck across all three Go modules for the first time. server
(23k lines) and desktop came back clean. agent had seven findings, and
one of them was not tidiness.

`stopGrace = 10 * time.Second` was declared and wired to nothing.
Stop() cancels the context, cmd.Cancel kills the process tree, and then
Stop() blocks on cmd.Wait() - which, with no WaitDelay set, waits not
just for the process but for every writer of its stdout pipe to close.
One grandchild still holding that pipe hangs Wait, hangs Stop, and on the
desktop app that is the tray's Quit never returning. The constant named
the intent and nothing read it. cmd.WaitDelay = stopGrace is the line
that was missing.

The rest were real but small: an unused field in the live relay, an
unused sleep helper in the pump, and "net/url" imported twice under two
names - both genuinely used, in two functions doing the same job for the
same reason, so they are unified rather than one deleted. My first pass
deleted the wrong one on a bad grep and the build caught it immediately.

Three findings are suppressed rather than fixed, with the reason stated:

- Two "error strings should not end with punctuation". Both are
  multi-line messages a shop operator reads at a counter, not errors
  anything wraps. ST1005 exists because wrapped errors concatenate
  mid-sentence; stripping the full stops would run three sentences
  together to satisfy a rule that does not apply.
- A deliberately nil context in a pump test - the point of the test is
  that an unconnected client does not panic. It already carried
  //nolint:staticcheck, which is golangci-lint's directive and
  staticcheck ignores, which is why it kept being reported.

Also tidied agent/go.mod, which had paho and x/sys marked indirect while
being imported directly.

All three modules clean, all suites pass: 21 Go packages, 226 engine
tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 15:14:09 +05:30

266 lines
8.9 KiB
Go

// Broker client: the thin adapter behind the Publisher interface.
//
// Everything that decides *what to send and when* is in pump.go and is tested
// without a broker. This file only knows how to put bytes on a topic, which is
// why it is the one part that needs a real connection to exercise.
//
// Targets Mosquitto. No clustering, no shared subscriptions, no broker-side
// rules — a store publishes its own events under its own prefix and that is
// the whole interaction.
package mqtt
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log"
"net"
"net/url"
"os"
"strings"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
)
// ClientOptions configures a broker connection.
type ClientOptions struct {
// BrokerURL is tls://host:8883 in production, tcp://host:1883 for local
// testing only. Credentials and footfall must never cross the internet in
// the clear, so Connect refuses tcp:// to a non-loopback host.
BrokerURL string
ClientID string
Username string
Password string
// CAFile pins a private CA. Empty uses the system roots, which is what a
// Let's Encrypt certificate on the broker needs.
CAFile string
// InsecureSkipVerify disables certificate checking. Only ever for a
// self-signed staging box, and it is logged loudly when set, because a
// forgotten one silently removes the protection TLS was added for.
InsecureSkipVerify bool
// PublishTimeout bounds a single publish. Without it a half-open
// connection blocks the pump indefinitely and the queue grows behind it.
PublishTimeout time.Duration
Log *log.Logger
}
// Client implements Publisher.
type Client struct {
opts ClientOptions
client paho.Client
}
// NewClient dials the broker. It returns as soon as the connection is
// established; reconnection afterwards is automatic and the pump reads
// Connected() to decide whether to try.
func NewClient(opts ClientOptions) (*Client, error) {
if opts.BrokerURL == "" {
return nil, errors.New("mqtt: no broker url")
}
if opts.PublishTimeout <= 0 {
opts.PublishTimeout = 10 * time.Second
}
if err := checkTransport(opts.BrokerURL); err != nil {
return nil, err
}
po := paho.NewClientOptions().
AddBroker(opts.BrokerURL).
SetClientID(opts.ClientID).
SetUsername(opts.Username).
SetPassword(opts.Password).
// The broker holds no state for us: every event is already durable on
// our own disk, so a clean session avoids the broker queueing a
// second copy we would then have to de-duplicate.
SetCleanSession(true).
SetAutoReconnect(true).
SetConnectRetry(true).
SetConnectRetryInterval(5 * time.Second).
SetMaxReconnectInterval(2 * time.Minute).
SetKeepAlive(30 * time.Second).
SetConnectTimeout(15 * time.Second).
// Publishes must fail fast rather than pile up in memory while the
// link is down; the spool is what holds them, not the client.
SetMessageChannelDepth(1).
SetOrderMatters(true)
if strings.HasPrefix(opts.BrokerURL, "tls://") ||
strings.HasPrefix(opts.BrokerURL, "ssl://") {
cfg, err := tlsConfig(opts)
if err != nil {
return nil, err
}
po.SetTLSConfig(cfg)
}
c := &Client{opts: opts}
po.OnConnect = func(paho.Client) { c.logf("broker connected: %s", opts.BrokerURL) }
po.OnConnectionLost = func(_ paho.Client, err error) {
c.logf("broker connection lost: %v", err)
}
c.client = paho.NewClient(po)
tok := c.client.Connect()
if !tok.WaitTimeout(20 * time.Second) {
// 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)
}
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
// and delete an event that was dropped on the wire. QoS 2 costs two extra
// round trips to remove a duplicate the server can drop itself from the event
// id — at-least-once with idempotent consumers is the cheaper contract.
func (c *Client) Publish(ctx context.Context, topic string, payload []byte) error {
if c.client == nil {
return errors.New("mqtt: no client")
}
if !c.client.IsConnected() {
return errors.New("mqtt: not connected")
}
tok := c.client.Publish(topic, 1, false, payload)
// Honour both the caller's context and a hard timeout: a half-open TCP
// connection can leave a token that never completes, which would stall the
// pump forever with the queue growing behind it.
done := make(chan struct{})
go func() { tok.Wait(); close(done) }()
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(c.opts.PublishTimeout):
return fmt.Errorf("mqtt: publish to %s timed out", topic)
case <-done:
return tok.Error()
}
}
// Connected reports whether the broker link is up.
func (c *Client) Connected() bool {
return c.client != nil && c.client.IsConnected()
}
// Close disconnects cleanly, giving in-flight publishes a moment to land.
func (c *Client) Close() {
if c.client != nil && c.client.IsConnected() {
c.client.Disconnect(1000)
}
}
func (c *Client) logf(format string, args ...any) {
if c.opts.Log != nil {
c.opts.Log.Printf(format, args...)
}
}
// checkTransport refuses plaintext MQTT to anywhere but the local machine.
//
// The payloads carry customer visit records and the connection carries the
// tenant's broker password. A tcp:// URL to a public host is not a
// configuration choice, it is a mistake, and it is one that works — which is
// exactly why it has to be rejected here rather than noticed later.
func checkTransport(raw string) error {
if !strings.HasPrefix(raw, "tcp://") && !strings.HasPrefix(raw, "mqtt://") {
return nil
}
if os.Getenv("BEHAVISION_ALLOW_PLAINTEXT_MQTT") == "1" {
return nil
}
// url.Parse, not hand-rolled splitting: an IPv6 literal is bracketed and
// full of colons, so scanning for the first ":" turns "[::1]:1883" into
// "[" and refuses a perfectly good loopback address.
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("mqtt: cannot parse broker url %q: %w", raw, err)
}
switch u.Hostname() {
case "localhost", "127.0.0.1", "::1", "":
return nil
}
return fmt.Errorf("mqtt: refusing plaintext connection to %q - use tls:// "+
"(set BEHAVISION_ALLOW_PLAINTEXT_MQTT=1 only for local testing)",
u.Hostname())
}
func tlsConfig(opts ClientOptions) (*tls.Config, error) {
cfg := &tls.Config{MinVersion: tls.VersionTLS12}
if opts.InsecureSkipVerify {
cfg.InsecureSkipVerify = true
if opts.Log != nil {
opts.Log.Print("WARNING: MQTT certificate verification is DISABLED")
}
return cfg, nil
}
if opts.CAFile == "" {
return cfg, nil // system roots
}
pem, err := os.ReadFile(opts.CAFile)
if err != nil {
return nil, fmt.Errorf("mqtt: ca file: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("mqtt: no certificates found in %s", opts.CAFile)
}
cfg.RootCAs = pool
return cfg, nil
}
// osWriteFile is indirected so tests can build without importing os twice.
var osWriteFile = os.WriteFile