// Package mqttclient is a thin wrapper around paho.MQTT for the // M4 MQTT ingest path. It is shared by: // // - cmd/ingestd (subscriber on ba/+/+/incoming) // - loadgen/cmd/mqtt (publisher on ba///incoming) // // Why a wrapper: // - hide the paho token-on-publish option behind a single // error-returning Publish() that waits for the QoS 1 ack // - one place to set the LastWill, MaxInflight, AutoReconnect // defaults that match our SPEC ยง22 protection model // - one place to format consistent client IDs ("ingestd-host") // so EMQX /admin/clients shows them cleanly // // M4 does NOT support persistent sessions; a subscriber restart // replays nothing. QoS 1 + dedupe is enough. package mqttclient import ( "context" "errors" "fmt" "log/slog" "net/url" "time" mqtt "github.com/eclipse/paho.mqtt.golang" ) // Config holds the connection parameters. Username/Password is // EMQX's built-in-db auth: when set, the broker uses them to // authenticate and to apply the per-user ACL from acl.conf. type Config struct { Broker string // tcp://emqx:1883 (or ssl:// for TLS in M11+) ClientID string // e.g. "ingestd-mqtt-" Username string // e.g. "ingestd" or "prom-prod-acme-001" Password string // EMQX built-in-db password (== HMAC secret for sources) Clean bool // true = no persistent session; M4 default } // Client is the small surface area we need: Connect, Subscribe, // Publish, Disconnect. The underlying paho.Client is hidden. type Client struct { cfg Config logger *slog.Logger inner mqtt.Client } // Connect dials the broker, sets LastWill (offline status) and // returns a ready-to-use Client. Returns an error if the initial // connect fails; the paho auto-reconnect handles subsequent // blips. func Connect(ctx context.Context, cfg Config, logger *slog.Logger) (*Client, error) { if cfg.Broker == "" { return nil, errors.New("mqttclient: empty Broker") } if cfg.ClientID == "" { return nil, errors.New("mqttclient: empty ClientID") } uri, err := url.Parse(cfg.Broker) if err != nil { return nil, fmt.Errorf("mqttclient: bad broker url %q: %w", cfg.Broker, err) } opts := mqtt.NewClientOptions(). AddBroker(uri.String()). SetClientID(cfg.ClientID). SetCleanSession(cfg.Clean). SetAutoReconnect(true). SetMaxReconnectInterval(10 * time.Second). SetConnectTimeout(10 * time.Second). SetWriteTimeout(10 * time.Second). SetKeepAlive(30 * time.Second). SetPingTimeout(10 * time.Second) if cfg.Username != "" { opts.SetUsername(cfg.Username) opts.SetPassword(cfg.Password) } // Last will: when we go away, EMQX publishes our "offline" // status. Useful for the M9 observability layer; for M4 the // message just lands in $SYS and is ignored. willTopic := "$ba/client/" + cfg.ClientID + "/status" opts.SetWill(willTopic, "offline", 1, false) c := &Client{cfg: cfg, logger: logger, inner: mqtt.NewClient(opts)} tok := c.inner.Connect() if !tok.WaitTimeout(15 * time.Second) { return nil, errors.New("mqttclient: connect timeout") } if err := tok.Error(); err != nil { return nil, fmt.Errorf("mqttclient: connect: %w", err) } logger.Info("mqtt connected", "broker", cfg.Broker, "client_id", cfg.ClientID) return c, nil } // Handler is the per-message callback for subscribers. The body // is the raw payload bytes; the topic is the full topic string // (so the caller can parse ba///...). type Handler func(topic string, body []byte) error // Subscribe registers a QoS 1 subscription on the given topic // filter. Returns when the SUBACK is received. The handler runs // in a paho-internal goroutine; if it returns an error we just // log it (paho does not support nack semantics on QoS 1). func (c *Client) Subscribe(topic string, h Handler) error { tok := c.inner.Subscribe(topic, 1, func(_ mqtt.Client, m mqtt.Message) { if err := h(m.Topic(), m.Payload()); err != nil { c.logger.Warn("mqtt handler", "err", err, "topic", m.Topic()) } }) if !tok.WaitTimeout(15 * time.Second) { return errors.New("mqttclient: subscribe timeout") } if err := tok.Error(); err != nil { return fmt.Errorf("mqttclient: subscribe: %w", err) } c.logger.Info("mqtt subscribed", "topic", topic) return nil } // Publish posts a single message at QoS 1 and waits for the // broker's PUBACK. Returns the ack error if any. The body is // copied by paho; callers can reuse their buffer. func (c *Client) Publish(topic string, body []byte) error { tok := c.inner.Publish(topic, 1, false, body) if !tok.WaitTimeout(15 * time.Second) { return errors.New("mqttclient: publish timeout") } if err := tok.Error(); err != nil { return fmt.Errorf("mqttclient: publish: %w", err) } return nil } // IsConnected returns the current connection state. Useful for // health endpoints. func (c *Client) IsConnected() bool { return c.inner.IsConnected() } // Disconnect sends a clean DISCONNECT and waits up to 5s. func (c *Client) Disconnect() { if c.inner.IsConnected() { c.inner.Disconnect(5000) } }