client.go 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. // Package mqttclient is a thin wrapper around paho.MQTT for the
  2. // M4 MQTT ingest path. It is shared by:
  3. //
  4. // - cmd/ingestd (subscriber on ba/+/+/incoming)
  5. // - loadgen/cmd/mqtt (publisher on ba/<co>/<src>/incoming)
  6. //
  7. // Why a wrapper:
  8. // - hide the paho token-on-publish option behind a single
  9. // error-returning Publish() that waits for the QoS 1 ack
  10. // - one place to set the LastWill, MaxInflight, AutoReconnect
  11. // defaults that match our SPEC §22 protection model
  12. // - one place to format consistent client IDs ("ingestd-host")
  13. // so EMQX /admin/clients shows them cleanly
  14. //
  15. // M4 does NOT support persistent sessions; a subscriber restart
  16. // replays nothing. QoS 1 + dedupe is enough.
  17. package mqttclient
  18. import (
  19. "context"
  20. "errors"
  21. "fmt"
  22. "log/slog"
  23. "net/url"
  24. "time"
  25. mqtt "github.com/eclipse/paho.mqtt.golang"
  26. )
  27. // Config holds the connection parameters. Username/Password is
  28. // EMQX's built-in-db auth: when set, the broker uses them to
  29. // authenticate and to apply the per-user ACL from acl.conf.
  30. type Config struct {
  31. Broker string // tcp://emqx:1883 (or ssl:// for TLS in M11+)
  32. ClientID string // e.g. "ingestd-mqtt-<host>"
  33. Username string // e.g. "ingestd" or "prom-prod-acme-001"
  34. Password string // EMQX built-in-db password (== HMAC secret for sources)
  35. Clean bool // true = no persistent session; M4 default
  36. }
  37. // Client is the small surface area we need: Connect, Subscribe,
  38. // Publish, Disconnect. The underlying paho.Client is hidden.
  39. type Client struct {
  40. cfg Config
  41. logger *slog.Logger
  42. inner mqtt.Client
  43. }
  44. // Connect dials the broker, sets LastWill (offline status) and
  45. // returns a ready-to-use Client. Returns an error if the initial
  46. // connect fails; the paho auto-reconnect handles subsequent
  47. // blips.
  48. func Connect(ctx context.Context, cfg Config, logger *slog.Logger) (*Client, error) {
  49. if cfg.Broker == "" {
  50. return nil, errors.New("mqttclient: empty Broker")
  51. }
  52. if cfg.ClientID == "" {
  53. return nil, errors.New("mqttclient: empty ClientID")
  54. }
  55. uri, err := url.Parse(cfg.Broker)
  56. if err != nil {
  57. return nil, fmt.Errorf("mqttclient: bad broker url %q: %w", cfg.Broker, err)
  58. }
  59. opts := mqtt.NewClientOptions().
  60. AddBroker(uri.String()).
  61. SetClientID(cfg.ClientID).
  62. SetCleanSession(cfg.Clean).
  63. SetAutoReconnect(true).
  64. SetMaxReconnectInterval(10 * time.Second).
  65. SetConnectTimeout(10 * time.Second).
  66. SetWriteTimeout(10 * time.Second).
  67. SetKeepAlive(30 * time.Second).
  68. SetPingTimeout(10 * time.Second)
  69. if cfg.Username != "" {
  70. opts.SetUsername(cfg.Username)
  71. opts.SetPassword(cfg.Password)
  72. }
  73. // Last will: when we go away, EMQX publishes our "offline"
  74. // status. Useful for the M9 observability layer; for M4 the
  75. // message just lands in $SYS and is ignored.
  76. willTopic := "$ba/client/" + cfg.ClientID + "/status"
  77. opts.SetWill(willTopic, "offline", 1, false)
  78. c := &Client{cfg: cfg, logger: logger, inner: mqtt.NewClient(opts)}
  79. tok := c.inner.Connect()
  80. if !tok.WaitTimeout(15 * time.Second) {
  81. return nil, errors.New("mqttclient: connect timeout")
  82. }
  83. if err := tok.Error(); err != nil {
  84. return nil, fmt.Errorf("mqttclient: connect: %w", err)
  85. }
  86. logger.Info("mqtt connected", "broker", cfg.Broker, "client_id", cfg.ClientID)
  87. return c, nil
  88. }
  89. // Handler is the per-message callback for subscribers. The body
  90. // is the raw payload bytes; the topic is the full topic string
  91. // (so the caller can parse ba/<co>/<src>/...).
  92. type Handler func(topic string, body []byte) error
  93. // Subscribe registers a QoS 1 subscription on the given topic
  94. // filter. Returns when the SUBACK is received. The handler runs
  95. // in a paho-internal goroutine; if it returns an error we just
  96. // log it (paho does not support nack semantics on QoS 1).
  97. func (c *Client) Subscribe(topic string, h Handler) error {
  98. tok := c.inner.Subscribe(topic, 1, func(_ mqtt.Client, m mqtt.Message) {
  99. if err := h(m.Topic(), m.Payload()); err != nil {
  100. c.logger.Warn("mqtt handler", "err", err, "topic", m.Topic())
  101. }
  102. })
  103. if !tok.WaitTimeout(15 * time.Second) {
  104. return errors.New("mqttclient: subscribe timeout")
  105. }
  106. if err := tok.Error(); err != nil {
  107. return fmt.Errorf("mqttclient: subscribe: %w", err)
  108. }
  109. c.logger.Info("mqtt subscribed", "topic", topic)
  110. return nil
  111. }
  112. // Publish posts a single message at QoS 1 and waits for the
  113. // broker's PUBACK. Returns the ack error if any. The body is
  114. // copied by paho; callers can reuse their buffer.
  115. func (c *Client) Publish(topic string, body []byte) error {
  116. tok := c.inner.Publish(topic, 1, false, body)
  117. if !tok.WaitTimeout(15 * time.Second) {
  118. return errors.New("mqttclient: publish timeout")
  119. }
  120. if err := tok.Error(); err != nil {
  121. return fmt.Errorf("mqttclient: publish: %w", err)
  122. }
  123. return nil
  124. }
  125. // IsConnected returns the current connection state. Useful for
  126. // health endpoints.
  127. func (c *Client) IsConnected() bool {
  128. return c.inner.IsConnected()
  129. }
  130. // Disconnect sends a clean DISCONNECT and waits up to 5s.
  131. func (c *Client) Disconnect() {
  132. if c.inner.IsConnected() {
  133. c.inner.Disconnect(5000)
  134. }
  135. }