# M4 Verification — MQTT ingest This is the live smoke test for the M4 milestone (**MQTT ingest**, SPEC §16 + §18 + §22). It exercises the new path end-to-end: 1. **EMQX** is up with **per-source ACLs** (default deny on `#`). The bootstrap file `deploy/emqx/auth-built-in-db-bootstrap.csv` declares the sources; `deploy/emqx/acl.conf` restricts each user to their own `ba///incoming` topic. 2. **`internal/mqttclient`** — shared paho.MQTT wrapper used by both the ingestd subscriber and the new loadgen publisher. 3. **`cmd/ingestd/mqtt.go`** — MQTT subscriber that runs every message through the same `processDeps.ProcessAlert` pipeline as the HTTP POST handler (parse → validate → HMAC verify → rate-limit → dedupe → publish to NATS). 4. **`loadgen/cmd/mqtt/`** — `loadgen-mqtt` publisher. Same data shape as `loadgen-http` (severity mix, dedupe ratio, burst mode). 5. **Topic namespace** — `ba///incoming`. Sources publish here; `ingestd` subscribes to the wildcard `ba/+/+/incoming`. The `+` is the EMQX single-level wildcard. 6. **Auth model** — twofold: - **MQTT username/password** for the broker-level gate. Username = `-`, password = HMAC secret (so the same secret serves both as MQTT auth and per-message HMAC). The `acl.conf` then gates which topic a given user can publish to. - **Per-message X-BA-Signature** in the JSON envelope's `auth` field. Same `verifyHMAC()` is reused unchanged. ## Prerequisites - Stack is up: `docker compose up -d` - All M1–M3 migrations applied: `docker compose up seed` - `BA_INGESTD_SOURCES` includes `acme-001:prom-prod:s3cret-acme` (and optionally `globex-002:grafana:s3cret-globex` for the ACL-violation test in step 5) - The M4 ingestd env vars are set: ``` BA_INGESTD_MQTT_BROKER=tcp://emqx:1883 BA_INGESTD_MQTT_USERNAME=ingestd BA_INGESTD_MQTT_PASSWORD=ingestd-broker-only BA_INGESTD_MQTT_SUBSCRIBE=ba/+/+/incoming ``` - The EMQX bootstrap files are mounted (in compose by default): `deploy/emqx/{acl.conf,auth-built-in-db-bootstrap.csv}`. - `loadgen-mqtt` is built: ```bash cd loadgen && go build -o /tmp/loadgen-mqtt ./cmd/mqtt ``` ## Automated smoke ```bash bash scripts/m4_smoke.sh ``` The script: 1. Builds `loadgen-mqtt` and the 3 failure-path test binaries (m4_badsig, m4_acl_violation, m4_badjson) if not already present. 2. Walks the 5 step-by-step scenarios below, asserting the expected delivery counts and metric counter deltas. 3. Exits 0 on success, $fail_count on failure. ## Step-by-step (also documented in `M4_SMOKE_LOG.md`) ### Step 1 — confirm M4 wiring is up ```bash # ingestd's MQTT subscriber is connected docker logs broad-announce-ingestd-1 2>&1 | tail -3 # should show: "mqtt connected" + "mqtt subscribed" + "ba/+/+/incoming" # EMQX dashboard is reachable curl -sS http://localhost:18083/ # EMQX 5.x landing page # emqx is healthy docker inspect broad-announce-emqx-1 --format '{{.State.Health.Status}}' # "healthy" ``` ### Step 2 — happy path: 1 alert via MQTT ```bash /tmp/loadgen-mqtt \ --broker tcp://localhost:1883 \ --api-key acme-001:prom-prod:s3cret-acme \ --count 1 --rate 5 ``` Wait 4s, then check `deliveries`: ```bash docker exec -i broad-announce-postgres-1 psql -U ba -d ba -c " SELECT individual_id, channel, status FROM deliveries ORDER BY id;" ``` **Expected**: 2 rows — Alice fcm (sent) + Alice telegram (sent). Bob has no `telegram_chat_id` linked at this point (still in M2 default state for the smoke). Ingestd's MQTT counters tick: ```bash curl -sS http://localhost:8800/metrics | grep ba_ingestd_mqtt # ba_ingestd_mqtt_messages_total{result="received",service="ingestd"} # ba_ingestd_mqtt_messages_total{result="accepted",service="ingestd"} ``` ### Step 3 — 5 alerts, mixed severity, 30% dedupe ```bash /tmp/loadgen-mqtt \ --broker tcp://localhost:1883 \ --api-key acme-001:prom-prod:s3cret-acme \ --count 5 --rate 5 --mode normal ``` **Expected**: 5 distinct `alert_id`s, ≥ 10 deliveries (5 alerts × 2 channels for Alice; +4 per alert with severity ≥ critical for Bob). At least 1 dedupe (30% of alerts share a `dedupe_key`). Ingestd counter `mqtt_messages{result="deduped"}` advances by ≥ 1. ### Step 4 — bad signature Build the test binary (script does it automatically): ```bash cat > /tmp/m4_smoke/badsig.go <<'GO' package main import ( "encoding/json" "fmt" mqtt "github.com/eclipse/paho.mqtt.golang" "os" "time" ) func main() { opts := mqtt.NewClientOptions(). AddBroker("tcp://localhost:1883"). SetClientID("m4-badsig"). SetUsername("prom-prod-acme-001"). SetPassword("s3cret-acme"). SetCleanSession(true) c := mqtt.NewClient(opts) if tok := c.Connect(); !tok.WaitTimeout(5*time.Second) || tok.Error() != nil { fmt.Println("connect err:", tok.Error()); os.Exit(0) } body, _ := json.Marshal(map[string]any{ "alert": json.RawMessage(`{"company_id":"acme-001","source_id":"prom-prod","severity":"info","title":"bad sig"}`), "auth": "t=1700000000,v1=deadbeef", }) tok := c.Publish("ba/acme-001/prom-prod/incoming", 1, false, body) if !tok.WaitTimeout(5*time.Second) { os.Exit(0) } fmt.Println("bad-sig published; err:", tok.Error()) os.Exit(0) } GO (cd loadgen && CGO_ENABLED=0 go build -o /tmp/m4_smoke/m4_badsig /tmp/m4_smoke/badsig.go) /tmp/m4_smoke/m4_badsig ``` **Expected**: - 0 deliveries (HMAC failed, alert dropped) - ingestd log: `WARN msg="mqtt alert rejected" reason=bad_signature` - `ba_ingestd_mqtt_messages_total{result="bad_signature"}` ticks ### Step 5 — ACL violation ```bash cat > /tmp/m4_smoke/acl_violation.go <<'GO' package main import ( "fmt" mqtt "github.com/eclipse/paho.mqtt.golang" "os" "time" ) func main() { opts := mqtt.NewClientOptions(). AddBroker("tcp://localhost:1883"). SetClientID("m4-acl-violation"). SetUsername("prom-prod-acme-001"). // <-- authed as acme source SetPassword("s3cret-acme"). SetCleanSession(true) c := mqtt.NewClient(opts) if tok := c.Connect(); !tok.WaitTimeout(5*time.Second) || tok.Error() != nil { fmt.Println("connect err:", tok.Error()); os.Exit(0) } // Try to publish to GLOBEX's topic — should be denied by ACL tok := c.Publish("ba/globex-002/grafana/incoming", 1, false, []byte("hack")) if !tok.WaitTimeout(5*time.Second) { os.Exit(0) } fmt.Println("published to wrong topic; err:", tok.Error()) os.Exit(0) } GO (cd loadgen && CGO_ENABLED=0 go build -o /tmp/m4_smoke/m4_acl_violation /tmp/m4_smoke/acl_violation.go) /tmp/m4_smoke/m4_acl_violation ``` **Expected**: - 0 deliveries (broker dropped the publish before the subscriber saw it) - EMQX log: `tag: AUTHZ, msg: cannot_publish_to_topic_due_to_not_authorized, ... topic: ba/globex-002/grafana/incoming` - `ba_ingestd_mqtt_messages_total{result="received"}` is **unchanged** (broker dropped before subscriber) ### Step 6 — non-JSON body ```bash cat > /tmp/m4_smoke/badjson.go <<'GO' package main import ( "fmt" mqtt "github.com/eclipse/paho.mqtt.golang" "os" "time" ) func main() { opts := mqtt.NewClientOptions(). AddBroker("tcp://localhost:1883"). SetClientID("m4-badjson"). SetUsername("prom-prod-acme-001"). SetPassword("s3cret-acme"). SetCleanSession(true) c := mqtt.NewClient(opts) if tok := c.Connect(); !tok.WaitTimeout(5*time.Second) || tok.Error() != nil { fmt.Println("connect err:", tok.Error()); os.Exit(0) } tok := c.Publish("ba/acme-001/prom-prod/incoming", 1, false, []byte("not json")) if !tok.WaitTimeout(5*time.Second) { os.Exit(0) } fmt.Println("bad-json published; err:", tok.Error()) os.Exit(0) } GO (cd loadgen && CGO_ENABLED=0 go build -o /tmp/m4_smoke/m4_badjson /tmp/m4_smoke/badjson.go) /tmp/m4_smoke/m4_badjson ``` **Expected**: - 0 deliveries (alert.Alert.Unmarshal failed) - ingestd log: `WARN msg="mqtt alert rejected" reason=invalid_json` - `ba_ingestd_mqtt_messages_total{result="invalid_json"}` ticks ## What this proves about M4 1. **Per-source ACLs work.** The `acl.conf` file uses Erlang-term rules keyed on the username. A user can publish only to its own `ba///incoming`. Step 5 proves a different source can't publish to another source's topic — the broker drops the publish before any subscriber sees it. 2. **The same pipeline serves HTTP and MQTT.** Steps 2 + 3 show the same `ProcessAlert` path that ran M0–M3's HTTP-only smoke now runs the MQTT path with no code changes to the rest of the stack (rate limits, dedupe, NATS publish, alert.ID, alert.DedupeCount all behave identically). 3. **Per-message HMAC is enforced.** Step 4 proves that even an authenticated source can't bypass per-message auth by sending an arbitrary body. 4. **Topic parsing is strict.** `parseIncomingTopic` only accepts `ba///incoming` (4 segments, last = `incoming`). Anything else lands in `mqtt_messages{result="bad_topic"}` and is dropped. The broker's ACL is the first line of defense; this is the second. 5. **Dedupe window is per-source, not per-transport.** The same `dedupe.Deduper` (Redis-backed) is reused for HTTP and MQTT. Step 3's 30% dedupe ratio produces the expected dedupe count in the deliveries table. 6. **Metrics are per-message.** `ba_ingestd_mqtt_messages_total{result="..."}` exposes received/accepted/deduped/bad_signature/bad_topic/ invalid_json/... as separate labels. M9 promotes these to the alerts-received total. ## Known quirks (and why the smoke script does what it does) 1. **faketgmd's `getUpdates` is a fake** (carried over from M3). The smoke script still calls `docker compose restart telegramd` between steps so the bot's in-process long-poll offset aligns with the reset state. Same quirk; same workaround. 2. **EMQX's `deny_action = disconnect`** is set, but in practice the paho client may reconnect fast enough that the test code sees "still connected" after a denied publish. The end-state is the same: the publish was dropped, no delivery happened, and `received` counter didn't tick. 3. **The 3 failure-path test binaries** (`m4_badsig`, `m4_acl_violation`, `m4_badjson`) are tiny, statically linked, and live in `/tmp/m4_smoke/`. The smoke script auto-builds them on first run. They are not committed to the repo. 4. **EMQX env-var config beats `emqx.conf`.** Earlier versions of the compose tried to volume-mount `emqx.conf`; EMQX 5.x rejects partial overrides there with `node.cookie required_field` (because `emqx.conf` is a *full* config, not a partial). The current compose uses env vars (`EMQX_AUTHENTICATION__1__BACKEND`, `EMQX_AUTHORIZATION__NO_MATCH`, etc.) and the volume mount is just `acl.conf` + the bootstrap CSV. ## What stays out of M4 (and not supposed to be) - Per-IP concurrency cap (M5 with WS, SPEC §22 layer 2) - Circuit breaker + quarantine (M9) - TLS to EMQX (M11, security milestone) - Persistent sessions (M11) - Per-company bot token resolution for sources that share a single EMQX user across companies (M3+ generalization) - HTTP-style signature in a header (MQTT has no headers; the `auth` field in the JSON envelope is the equivalent)