| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211 |
- // loadgen/cmd/mqtt is the M4 MQTT publisher for broad-announce.
- // Same data shape as loadgen/cmd/http (severity mix, dedupe
- // ratio, company cycling) but posts to an MQTT broker on
- // ba/<co>/<src>/incoming. Auth is twofold:
- //
- // 1. MQTT username/password: prom-prod-acme-001 / s3cret-acme
- // (the EMQX built-in-db row from deploy/emqx/auth-built-in-
- // db-bootstrap.csv). The broker's ACL is keyed on the
- // username, so this is what gates topic access.
- // 2. Per-message X-BA-Signature in the JSON envelope's `auth`
- // field, identical to HTTP. This is the per-message HMAC
- // the ingestd mqtt subscriber re-uses verifyHMAC() to check.
- //
- // Example:
- //
- // loadgen-mqtt --broker tcp://localhost:1883 \
- // --api-key acme-001:prom-prod:s3cret-acme \
- // --count 10 --rate 5
- package main
- import (
- "context"
- "crypto/hmac"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "flag"
- "fmt"
- "log/slog"
- "math/rand/v2"
- "os"
- "os/signal"
- "strings"
- "sync"
- "sync/atomic"
- "syscall"
- "time"
- "git3.techno-world.net/lrosales/broad-announce/internal/mqttclient"
- )
- func main() {
- var (
- broker = flag.String("broker", "tcp://localhost:1883", "MQTT broker URL")
- apiKey = flag.String("api-key", "", "company:source:secret")
- count = flag.Int("count", 10, "total alerts to send")
- rate = flag.Int("rate", 10, "target alerts/sec")
- mode = flag.String("mode", "normal", "profile: normal|burst")
- dedupePct = flag.Int("dedupe-pct", 30, "percent sharing a dedupe_key (normal)")
- metrics = flag.String("metrics", "", "Prometheus metrics listen addr (empty to disable)")
- timeout = flag.Duration("duration", 30*time.Second, "max run time")
- )
- flag.Parse()
- if *apiKey == "" {
- fmt.Fprintln(os.Stderr, "loadgen-mqtt: --api-key is required (company:source:secret)")
- os.Exit(2)
- }
- parts := strings.SplitN(*apiKey, ":", 3)
- if len(parts) != 3 {
- fmt.Fprintln(os.Stderr, "loadgen-mqtt: --api-key must be company:source:secret")
- os.Exit(2)
- }
- company, source, secret := parts[0], parts[1], parts[2]
- username := fmt.Sprintf("%s-%s", source, company) // matches auth csv
- logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
- defer stop()
- host, _ := os.Hostname()
- client, err := mqttclient.Connect(ctx, mqttclient.Config{
- Broker: *broker,
- ClientID: fmt.Sprintf("loadgen-mqtt-%s", host),
- Username: username,
- Password: secret,
- Clean: true,
- }, logger)
- if err != nil {
- fmt.Fprintln(os.Stderr, "loadgen-mqtt: connect:", err)
- os.Exit(1)
- }
- defer client.Disconnect()
- topic := fmt.Sprintf("ba/%s/%s/incoming", company, source)
- logger.Info("publishing", "topic", topic, "count", *count, "rate", *rate, "mode", *mode)
- var (
- sent atomic.Uint64
- failed atomic.Uint64
- dupes atomic.Uint64
- )
- limiter := time.NewTicker(time.Second / time.Duration(*rate))
- defer limiter.Stop()
- var wg sync.WaitGroup
- work := make(chan int, 64)
- wg.Add(1)
- go func() {
- defer wg.Done()
- for i := 0; i < *count; i++ {
- work <- i
- }
- close(work)
- }()
- deadline := time.Now().Add(*timeout)
- for i := range work {
- if time.Now().After(deadline) {
- logger.Warn("deadline reached, stopping")
- break
- }
- <-limiter.C
- a := makeAlert(i, *mode, *dedupePct, *dedupeKey, company, source)
- ts := time.Now().Unix()
- // HTTP-style signature over the alert body, with t= prefix.
- // The MQTT subscriber's verifyHMAC accepts the same shape.
- body, _ := json.Marshal(a)
- mac := hmac.New(sha256.New, []byte(secret))
- mac.Write([]byte(fmt.Sprintf("%d", ts)))
- mac.Write([]byte("."))
- mac.Write(body)
- sig := hex.EncodeToString(mac.Sum(nil))
- env := map[string]json.RawMessage{
- "alert": body,
- }
- env["auth"] = json.RawMessage(fmt.Sprintf("%q", fmt.Sprintf("t=%d,v1=%s", ts, sig)))
- envelope, _ := json.Marshal(env)
- if err := client.Publish(topic, envelope); err != nil {
- failed.Add(1)
- logger.Warn("publish failed", "err", err, "i", i)
- continue
- }
- if i > 0 && i%(*count/10+1) == 0 {
- logger.Info("progress", "sent", i, "total", *count)
- }
- if !*isUnique(*dedupePct, i) {
- dupes.Add(1)
- }
- sent.Add(1)
- }
- wg.Wait()
- logger.Info("done",
- "sent", sent.Load(),
- "failed", failed.Load(),
- "dupes", dupes.Load(),
- )
- if failed.Load() > 0 {
- os.Exit(1)
- }
- _ = metrics // reserved for M9
- }
- // makeAlert generates one alert. Same shape as loadgen/cmd/http.
- func makeAlert(idx int, mode string, dedupePct int, forceKey, company, source string) map[string]any {
- severity := pickSeverity(mode)
- // M6: --dedupe-key forces a specific key on every alert,
- // which is what the ×N smoke test needs. Otherwise we
- // use the M4 distribution: 30% share a key, 70% get a
- // unique one.
- dedupeKey := fmt.Sprintf("lg-m4-%d", idx)
- if forceKey != "" {
- dedupeKey = forceKey
- } else if dedupePct > 0 && idx > 0 && rand.IntN(100) < dedupePct {
- dedupeKey = "lg-m4-shared"
- }
- return map[string]any{
- "company_id": company,
- "source_id": source,
- "severity": severity,
- "category": "loadgen",
- "title": fmt.Sprintf("LG M4 #%d", idx),
- "body": "mqtt smoke",
- "data": map[string]string{"host": "lg-host", "idx": fmt.Sprintf("%d", idx)},
- "dedupe_key": dedupeKey,
- }
- }
- func pickSeverity(mode string) string {
- r := rand.IntN(100)
- switch mode {
- case "burst":
- // Mostly critical to exercise the bypass.
- switch {
- case r < 70:
- return "critical"
- case r < 95:
- return "inminent_colapse"
- default:
- return "warning"
- }
- default: // "normal"
- switch {
- case r < 70:
- return "info"
- case r < 95:
- return "warning"
- case r < 99:
- return "critical"
- default:
- return "inminent_colapse"
- }
- }
- }
- func isUnique(dedupePct, i int) *bool {
- b := i == 0 || dedupePct == 0 || rand.IntN(100) >= dedupePct
- return &b
- }
|