mqtt.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. // mqtt.go is the M4 MQTT subscriber for ingestd. It connects
  2. // to EMQX, subscribes to ba/+/+/incoming (QoS 1), and runs
  3. // every message through the same processDeps.ProcessAlert
  4. // pipeline as the HTTP POST handler. The pipeline is shared;
  5. // the only MQTT-specific work here is:
  6. //
  7. // 1. dial the broker via internal/mqttclient
  8. // 2. parse the topic ba/<co>/<src>/incoming to recover the
  9. // (company_id, source_id) pair
  10. // 3. extract the X-BA-Signature from the JSON envelope's
  11. // _auth field (MQTT has no headers; the signature rides
  12. // alongside the alert body)
  13. // 4. map the Result back to a log line and a metric
  14. //
  15. // The X-BA-Signature format is identical to HTTP (t=<unix>,
  16. // v1=<hex>) so verifyHMAC works unchanged.
  17. package main
  18. import (
  19. "context"
  20. "encoding/json"
  21. "fmt"
  22. "log/slog"
  23. "net/url"
  24. "os"
  25. "strings"
  26. "time"
  27. "git3.techno-world.net/lrosales/broad-announce/internal/mqttclient"
  28. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  29. )
  30. // mqttIngestConfig is the read-only config the MQTT subscriber
  31. // needs from env. It is constructed once at startup.
  32. type mqttIngestConfig struct {
  33. Broker string // BA_INGESTD_MQTT_BROKER (e.g. tcp://emqx:1883)
  34. Username string // BA_INGESTD_MQTT_USERNAME
  35. Password string // BA_INGESTD_MQTT_PASSWORD
  36. Subscribe string // BA_INGESTD_MQTT_SUBSCRIBE (e.g. ba/+/+/incoming)
  37. ClientID string // optional override; default is "ingestd-mqtt-<host>"
  38. }
  39. func loadMQTTConfig(logger *slog.Logger) mqttIngestConfig {
  40. host, _ := os.Hostname()
  41. cfg := mqttIngestConfig{
  42. Broker: os.Getenv("BA_INGESTD_MQTT_BROKER"),
  43. Username: os.Getenv("BA_INGESTD_MQTT_USERNAME"),
  44. Password: os.Getenv("BA_INGESTD_MQTT_PASSWORD"),
  45. Subscribe: os.Getenv("BA_INGESTD_MQTT_SUBSCRIBE"),
  46. ClientID: os.Getenv("BA_INGESTD_MQTT_CLIENT_ID"),
  47. }
  48. if cfg.Subscribe == "" {
  49. cfg.Subscribe = "ba/+/+/incoming"
  50. }
  51. if cfg.ClientID == "" {
  52. cfg.ClientID = fmt.Sprintf("ingestd-mqtt-%s", host)
  53. }
  54. logger.Info("mqtt config",
  55. "broker", cfg.Broker,
  56. "username", cfg.Username,
  57. "subscribe", cfg.Subscribe,
  58. "client_id", cfg.ClientID,
  59. )
  60. return cfg
  61. }
  62. // mqttEnvelope is the wire shape on the MQTT topic. We keep the
  63. // alert body in `alert` (the same alert.Alert) and carry the
  64. // HTTP-style signature in `auth` (a string like
  65. // "t=1700000000,v1=deadbeef..."). This is the only M4-specific
  66. // addition to the alert payload and is removed by the time the
  67. // alert hits NATS.
  68. type mqttEnvelope struct {
  69. Alert json.RawMessage `json:"alert"`
  70. Auth string `json:"auth,omitempty"`
  71. }
  72. // startMQTT dials the broker and returns when the subscription
  73. // is live. It blocks until ctx is cancelled, then disconnects
  74. // cleanly. Errors here are fatal for ingestd (the spec says
  75. // every alert must be available via every transport).
  76. func startMQTT(ctx context.Context, cfg mqttIngestConfig, pdeps *processDeps, logger *slog.Logger, m *observability.IngestdMetrics) error {
  77. if cfg.Broker == "" {
  78. logger.Warn("BA_INGESTD_MQTT_BROKER not set; MQTT ingest disabled")
  79. <-ctx.Done()
  80. return nil
  81. }
  82. if _, err := url.Parse(cfg.Broker); err != nil {
  83. return fmt.Errorf("mqtt broker url: %w", err)
  84. }
  85. client, err := mqttclient.Connect(ctx, mqttclient.Config{
  86. Broker: cfg.Broker,
  87. ClientID: cfg.ClientID,
  88. Username: cfg.Username,
  89. Password: cfg.Password,
  90. Clean: true, // M4: no persistent session; QoS 1 + dedupe is enough
  91. }, logger.With("subsystem", "mqtt"))
  92. if err != nil {
  93. return fmt.Errorf("mqtt connect: %w", err)
  94. }
  95. defer client.Disconnect()
  96. if err := client.Subscribe(cfg.Subscribe, func(topic string, body []byte) error {
  97. m.MQTTMessages.WithLabelValues("received").Inc()
  98. handleMQTTMessage(ctx, topic, body, pdeps, m, logger)
  99. return nil // log on the way down; paho QoS 1 has no nack
  100. }); err != nil {
  101. return fmt.Errorf("mqtt subscribe: %w", err)
  102. }
  103. // Park on ctx; the paho library owns the message loop.
  104. <-ctx.Done()
  105. return nil
  106. }
  107. // handleMQTTMessage is the per-message pipeline for MQTT.
  108. // Order: parse topic → unmarshal envelope → extract
  109. // X-BA-Signature → run the shared processDeps.ProcessAlert →
  110. // log + count.
  111. //
  112. // We always ACK the message (paho's QoS 1 has already acked on
  113. // receive). Failures land in a metric + warn log.
  114. func handleMQTTMessage(ctx context.Context, topic string, body []byte, pdeps *processDeps, m *observability.IngestdMetrics, logger *slog.Logger) {
  115. companyID, sourceID, perr := parseIncomingTopic(topic)
  116. if perr != nil {
  117. m.MQTTMessages.WithLabelValues("bad_topic").Inc()
  118. logger.Warn("mqtt bad topic", "topic", topic, "err", perr)
  119. return
  120. }
  121. // The MQTT body is the envelope {alert, auth}. We could
  122. // also accept a bare alert (no envelope) for compatibility
  123. // with future broker-native clients, but M4 ships envelope
  124. // only. Envelope presence is detected by sniffing the first
  125. // non-whitespace byte.
  126. var env mqttEnvelope
  127. alertBody := body
  128. sigHeader := ""
  129. if len(body) > 0 && body[0] == '{' {
  130. // Looks like JSON. Try envelope first; fall back to
  131. // bare-alert (no signature) on shape mismatch.
  132. if err := json.Unmarshal(body, &env); err == nil && len(env.Alert) > 0 {
  133. alertBody = env.Alert
  134. sigHeader = env.Auth
  135. }
  136. }
  137. // Run the shared pipeline. The ProcessAlert will re-parse
  138. // the (company_id, source_id) from the alert body; we trust
  139. // the topic only for metric labels.
  140. res := pdeps.ProcessAlert(ctx, alertBody, sigHeader)
  141. if res.Accepted {
  142. m.MQTTMessages.WithLabelValues("accepted").Inc()
  143. if !res.IsNew {
  144. m.MQTTMessages.WithLabelValues("deduped").Inc()
  145. }
  146. logger.Info("mqtt alert accepted",
  147. "alert_id", res.AlertID,
  148. "topic_company", companyID,
  149. "topic_source", sourceID,
  150. "dedupe_count", res.DedupeCount,
  151. )
  152. return
  153. }
  154. m.MQTTMessages.WithLabelValues(res.RejectReason).Inc()
  155. logger.Warn("mqtt alert rejected",
  156. "topic", topic,
  157. "company", companyID,
  158. "source", sourceID,
  159. "reason", res.RejectReason,
  160. "detail", res.Detail,
  161. )
  162. }
  163. // parseIncomingTopic accepts "ba/<company>/<source>/incoming"
  164. // and returns the (company_id, source_id). Strict: a topic that
  165. // doesn't match the 4-segment pattern is rejected. A source
  166. // that publishes to ba/foo/bar/anything-else is rejected too —
  167. // the ACL stops them at the broker, but we double-check.
  168. func parseIncomingTopic(topic string) (string, string, error) {
  169. parts := strings.Split(topic, "/")
  170. if len(parts) != 4 || parts[0] != "ba" || parts[3] != "incoming" {
  171. return "", "", fmt.Errorf("topic must be ba/<co>/<src>/incoming, got %q", topic)
  172. }
  173. return parts[1], parts[2], nil
  174. }
  175. // IngestdMetrics is a forward declaration to avoid a circular
  176. // import (observability defines the struct; mqtt.go references
  177. // the additional counter MQTTMessages). The field is added in
  178. // observability in this same commit.
  179. var _ = time.Now // keep import