main.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. // loadgen/cmd/mqtt is the M4 MQTT publisher for broad-announce.
  2. // Same data shape as loadgen/cmd/http (severity mix, dedupe
  3. // ratio, company cycling) but posts to an MQTT broker on
  4. // ba/<co>/<src>/incoming. Auth is twofold:
  5. //
  6. // 1. MQTT username/password: prom-prod-acme-001 / s3cret-acme
  7. // (the EMQX built-in-db row from deploy/emqx/auth-built-in-
  8. // db-bootstrap.csv). The broker's ACL is keyed on the
  9. // username, so this is what gates topic access.
  10. // 2. Per-message X-BA-Signature in the JSON envelope's `auth`
  11. // field, identical to HTTP. This is the per-message HMAC
  12. // the ingestd mqtt subscriber re-uses verifyHMAC() to check.
  13. //
  14. // Example:
  15. //
  16. // loadgen-mqtt --broker tcp://localhost:1883 \
  17. // --api-key acme-001:prom-prod:s3cret-acme \
  18. // --count 10 --rate 5
  19. package main
  20. import (
  21. "context"
  22. "crypto/hmac"
  23. "crypto/sha256"
  24. "encoding/hex"
  25. "encoding/json"
  26. "flag"
  27. "fmt"
  28. "log/slog"
  29. "math/rand/v2"
  30. "os"
  31. "os/signal"
  32. "strings"
  33. "sync"
  34. "sync/atomic"
  35. "syscall"
  36. "time"
  37. "git3.techno-world.net/lrosales/broad-announce/internal/mqttclient"
  38. )
  39. func main() {
  40. var (
  41. broker = flag.String("broker", "tcp://localhost:1883", "MQTT broker URL")
  42. apiKey = flag.String("api-key", "", "company:source:secret")
  43. count = flag.Int("count", 10, "total alerts to send")
  44. rate = flag.Int("rate", 10, "target alerts/sec")
  45. mode = flag.String("mode", "normal", "profile: normal|burst")
  46. dedupePct = flag.Int("dedupe-pct", 30, "percent sharing a dedupe_key (normal)")
  47. metrics = flag.String("metrics", "", "Prometheus metrics listen addr (empty to disable)")
  48. timeout = flag.Duration("duration", 30*time.Second, "max run time")
  49. )
  50. flag.Parse()
  51. if *apiKey == "" {
  52. fmt.Fprintln(os.Stderr, "loadgen-mqtt: --api-key is required (company:source:secret)")
  53. os.Exit(2)
  54. }
  55. parts := strings.SplitN(*apiKey, ":", 3)
  56. if len(parts) != 3 {
  57. fmt.Fprintln(os.Stderr, "loadgen-mqtt: --api-key must be company:source:secret")
  58. os.Exit(2)
  59. }
  60. company, source, secret := parts[0], parts[1], parts[2]
  61. username := fmt.Sprintf("%s-%s", source, company) // matches auth csv
  62. logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
  63. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  64. defer stop()
  65. host, _ := os.Hostname()
  66. client, err := mqttclient.Connect(ctx, mqttclient.Config{
  67. Broker: *broker,
  68. ClientID: fmt.Sprintf("loadgen-mqtt-%s", host),
  69. Username: username,
  70. Password: secret,
  71. Clean: true,
  72. }, logger)
  73. if err != nil {
  74. fmt.Fprintln(os.Stderr, "loadgen-mqtt: connect:", err)
  75. os.Exit(1)
  76. }
  77. defer client.Disconnect()
  78. topic := fmt.Sprintf("ba/%s/%s/incoming", company, source)
  79. logger.Info("publishing", "topic", topic, "count", *count, "rate", *rate, "mode", *mode)
  80. var (
  81. sent atomic.Uint64
  82. failed atomic.Uint64
  83. dupes atomic.Uint64
  84. )
  85. limiter := time.NewTicker(time.Second / time.Duration(*rate))
  86. defer limiter.Stop()
  87. var wg sync.WaitGroup
  88. work := make(chan int, 64)
  89. wg.Add(1)
  90. go func() {
  91. defer wg.Done()
  92. for i := 0; i < *count; i++ {
  93. work <- i
  94. }
  95. close(work)
  96. }()
  97. deadline := time.Now().Add(*timeout)
  98. for i := range work {
  99. if time.Now().After(deadline) {
  100. logger.Warn("deadline reached, stopping")
  101. break
  102. }
  103. <-limiter.C
  104. a := makeAlert(i, *mode, *dedupePct, *dedupeKey, company, source)
  105. ts := time.Now().Unix()
  106. // HTTP-style signature over the alert body, with t= prefix.
  107. // The MQTT subscriber's verifyHMAC accepts the same shape.
  108. body, _ := json.Marshal(a)
  109. mac := hmac.New(sha256.New, []byte(secret))
  110. mac.Write([]byte(fmt.Sprintf("%d", ts)))
  111. mac.Write([]byte("."))
  112. mac.Write(body)
  113. sig := hex.EncodeToString(mac.Sum(nil))
  114. env := map[string]json.RawMessage{
  115. "alert": body,
  116. }
  117. env["auth"] = json.RawMessage(fmt.Sprintf("%q", fmt.Sprintf("t=%d,v1=%s", ts, sig)))
  118. envelope, _ := json.Marshal(env)
  119. if err := client.Publish(topic, envelope); err != nil {
  120. failed.Add(1)
  121. logger.Warn("publish failed", "err", err, "i", i)
  122. continue
  123. }
  124. if i > 0 && i%(*count/10+1) == 0 {
  125. logger.Info("progress", "sent", i, "total", *count)
  126. }
  127. if !*isUnique(*dedupePct, i) {
  128. dupes.Add(1)
  129. }
  130. sent.Add(1)
  131. }
  132. wg.Wait()
  133. logger.Info("done",
  134. "sent", sent.Load(),
  135. "failed", failed.Load(),
  136. "dupes", dupes.Load(),
  137. )
  138. if failed.Load() > 0 {
  139. os.Exit(1)
  140. }
  141. _ = metrics // reserved for M9
  142. }
  143. // makeAlert generates one alert. Same shape as loadgen/cmd/http.
  144. func makeAlert(idx int, mode string, dedupePct int, forceKey, company, source string) map[string]any {
  145. severity := pickSeverity(mode)
  146. // M6: --dedupe-key forces a specific key on every alert,
  147. // which is what the ×N smoke test needs. Otherwise we
  148. // use the M4 distribution: 30% share a key, 70% get a
  149. // unique one.
  150. dedupeKey := fmt.Sprintf("lg-m4-%d", idx)
  151. if forceKey != "" {
  152. dedupeKey = forceKey
  153. } else if dedupePct > 0 && idx > 0 && rand.IntN(100) < dedupePct {
  154. dedupeKey = "lg-m4-shared"
  155. }
  156. return map[string]any{
  157. "company_id": company,
  158. "source_id": source,
  159. "severity": severity,
  160. "category": "loadgen",
  161. "title": fmt.Sprintf("LG M4 #%d", idx),
  162. "body": "mqtt smoke",
  163. "data": map[string]string{"host": "lg-host", "idx": fmt.Sprintf("%d", idx)},
  164. "dedupe_key": dedupeKey,
  165. }
  166. }
  167. func pickSeverity(mode string) string {
  168. r := rand.IntN(100)
  169. switch mode {
  170. case "burst":
  171. // Mostly critical to exercise the bypass.
  172. switch {
  173. case r < 70:
  174. return "critical"
  175. case r < 95:
  176. return "inminent_colapse"
  177. default:
  178. return "warning"
  179. }
  180. default: // "normal"
  181. switch {
  182. case r < 70:
  183. return "info"
  184. case r < 95:
  185. return "warning"
  186. case r < 99:
  187. return "critical"
  188. default:
  189. return "inminent_colapse"
  190. }
  191. }
  192. }
  193. func isUnique(dedupePct, i int) *bool {
  194. b := i == 0 || dedupePct == 0 || rand.IntN(100) >= dedupePct
  195. return &b
  196. }