process.go 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. // process.go is the shared alert-processing pipeline used by
  2. // both the HTTP handler (cmd/ingestd/http.go) and the MQTT
  3. // subscriber (cmd/ingestd/mqtt.go). It does the M0→M4 SPEC §22
  4. // work in one place: parse → validate → look up source → HMAC
  5. // verify → rate-limit (per-source, per-company) → dedupe → stamp
  6. // → publish to NATS → return a result struct the caller maps
  7. // to its own transport-level response.
  8. //
  9. // The function is pure (no global state, no transport types).
  10. // It returns Result{Accepted/Rejected/Error + reason + alert_id
  11. // + dedupe_count} so the HTTP handler can map to a status code
  12. // and the MQTT handler can map to a log line.
  13. package main
  14. import (
  15. "context"
  16. "encoding/json"
  17. "fmt"
  18. "log/slog"
  19. "strconv"
  20. "time"
  21. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  22. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  23. "git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
  24. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  25. "git3.techno-world.net/lrosales/broad-announce/internal/ratelimit"
  26. "github.com/nats-io/nats.go"
  27. )
  28. // Result is the outcome of a ProcessAlert call. The caller
  29. // (HTTP/MQTT) maps it to its transport's response shape.
  30. type Result struct {
  31. // Accepted is true if the alert passed all checks and was
  32. // published to NATS.
  33. Accepted bool
  34. // AlertID is the server-assigned id (empty on error paths).
  35. AlertID string
  36. // DedupeCount is the count returned by the dedupe layer.
  37. DedupeCount uint32
  38. // IsNew is true for the first alert in a dedupe window.
  39. IsNew bool
  40. // RejectReason is the SPEC §22 / auth reason; one of:
  41. // "payload_too_large", "bad_request", "invalid_json",
  42. // "invalid", "unknown_source", "bad_signature",
  43. // "rate_limited_source", "rate_limited_company",
  44. // "marshal_failed", "broker_unavailable"
  45. RejectReason string
  46. // HTTPStatus is the suggested HTTP status code (0 for
  47. // accepted / 202).
  48. HTTPStatus int
  49. // Detail is the free-form string the caller can show in
  50. // a response body or a log message.
  51. Detail string
  52. }
  53. // Accept is the canonical "ok" result.
  54. func Accept(id string, count uint32, isNew bool) Result {
  55. return Result{Accepted: true, AlertID: id, DedupeCount: count, IsNew: isNew, HTTPStatus: 202}
  56. }
  57. // Reject is the canonical "no" result.
  58. func Reject(reason string, status int, detail string) Result {
  59. return Result{RejectReason: reason, HTTPStatus: status, Detail: detail}
  60. }
  61. // processDeps is the process-pipeline dependency set. Smaller
  62. // than httpDeps — no HTTP-specific fields. Both httpDeps and
  63. // the MQTT subscriber construct one and call ProcessAlert.
  64. type processDeps struct {
  65. Logger *slog.Logger
  66. Metrics *observability.IngestdMetrics
  67. Limiter *ratelimit.Limiter
  68. Deduper *dedupe.Deduper
  69. JetStream natsPublisher
  70. // Sources is the (company_id, source_id) → SourceConfig map.
  71. // M0 reads it from env; M2 from Postgres. The MQTT subscriber
  72. // uses the same map keyed on the topic-parsed (co, src).
  73. Sources map[string]SourceConfig
  74. // Per-company default rate cap. HTTP and MQTT use the same
  75. // constant; once the per-company cap lives in DB, both
  76. // transports read it.
  77. CompanyRatePerSec int
  78. // Now is overridable in tests.
  79. Now func() time.Time
  80. }
  81. // ProcessAlert runs the full SPEC §22 protection chain on one
  82. // alert body. It is the single source of truth for the ingest
  83. // pipeline; both the HTTP POST handler and the MQTT subscriber
  84. // call it.
  85. //
  86. // Layer order (matches SPEC §22):
  87. // 1. payload-size cap (caller does this — http.go via
  88. // MaxBytesReader; mqtt.go via the
  89. // broker-side max-inflight setting)
  90. // 3. per-source rate limit
  91. // 4. per-company rate limit
  92. // 5. schema validate
  93. // 5b. parse JSON
  94. // (auth) HMAC verify — see verifyHMAC
  95. // 5c. dedupe
  96. // publish to NATS
  97. func (d *processDeps) ProcessAlert(ctx context.Context, body []byte, sigHeader string) Result {
  98. now := d.now()
  99. // 5. Parse + validate. We treat any parse failure as invalid.
  100. var a alert.Alert
  101. if err := json.Unmarshal(body, &a); err != nil {
  102. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  103. return Reject("invalid_json", 400, err.Error())
  104. }
  105. if err := a.Validate(); err != nil {
  106. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  107. return Reject("invalid", 400, err.Error())
  108. }
  109. // Look up source. M0: in-memory map. M2: DB.
  110. src, ok := d.Sources[a.CompanyID+":"+a.SourceID]
  111. if !ok {
  112. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  113. return Reject("unknown_source", 401,
  114. fmt.Sprintf("no such source %s/%s", a.CompanyID, a.SourceID))
  115. }
  116. // Auth. Stripe-style: X-BA-Signature: t=<unix>,v1=<hex>.
  117. // For HTTP it's a header; for MQTT it's a top-level field
  118. // on the envelope — both call sites pass the same string.
  119. if !verifyHMAC(sigHeader, src.HMACSecret, body, now) {
  120. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  121. return Reject("bad_signature", 401, "")
  122. }
  123. // 3. Per-source rate limit.
  124. if ok, ttl, err := d.Limiter.Allow(ctx, "source:"+a.CompanyID+":"+a.SourceID, src.RateLimitPerSec); err != nil {
  125. d.Logger.Warn("ratelimit redis error (failing open)", "err", err, "scope", "source")
  126. } else if !ok {
  127. d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
  128. d.Metrics.RateLimitHits.WithLabelValues("source").Inc()
  129. _ = ttl
  130. return Reject("rate_limited_source", 429, strconv.Itoa(int(ttl.Seconds())))
  131. }
  132. // 4. Per-company rate limit.
  133. if ok, ttl, _ := d.Limiter.Allow(ctx, "company:"+a.CompanyID, d.CompanyRatePerSec); !ok {
  134. d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
  135. d.Metrics.RateLimitHits.WithLabelValues("company").Inc()
  136. _ = ttl
  137. return Reject("rate_limited_company", 429, strconv.Itoa(int(ttl.Seconds())))
  138. }
  139. // 5c. Dedupe.
  140. isNew, count, err := d.Deduper.Check(ctx, a.SourceID, a.DedupeKey)
  141. if err != nil {
  142. d.Logger.Warn("dedupe redis error (failing open)", "err", err)
  143. isNew, count = true, 1
  144. }
  145. // Stamp server-side fields.
  146. a.ID = alert.NewID()
  147. a.ReceivedAt = now.UTC()
  148. a.DedupeCount = count
  149. // Publish to NATS.
  150. subject := broker.AlertsSubject(a.CompanyID)
  151. payload, err := json.Marshal(a)
  152. if err != nil {
  153. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  154. return Reject("marshal_failed", 500, err.Error())
  155. }
  156. start := time.Now()
  157. if err := d.JetStream.PublishAsync(subject, payload); err != nil {
  158. // Circuit breaker (M9) wraps this. For M0 we just fail loud.
  159. d.Metrics.AlertsReceived.WithLabelValues("circuit_open").Inc()
  160. return Reject("broker_unavailable", 503, err.Error())
  161. }
  162. d.Metrics.PublishLatency.Observe(time.Since(start).Seconds())
  163. d.Metrics.PayloadBytes.Observe(float64(len(payload)))
  164. if isNew {
  165. d.Metrics.AlertsReceived.WithLabelValues("accepted").Inc()
  166. } else {
  167. d.Metrics.AlertsReceived.WithLabelValues("deduped").Inc()
  168. }
  169. d.Logger.Info("alert accepted",
  170. "alert_id", a.ID,
  171. "company_id", a.CompanyID,
  172. "source_id", a.SourceID,
  173. "severity", string(a.Severity),
  174. "dedupe_count", count,
  175. )
  176. return Accept(a.ID, count, isNew)
  177. }
  178. func (d *processDeps) now() time.Time {
  179. if d.Now != nil {
  180. return d.Now()
  181. }
  182. return time.Now()
  183. }
  184. // natsPublisher is the minimal NATS interface. The HTTP and
  185. // MQTT paths share it; tests can swap in a fake.
  186. type natsPublisher interface {
  187. PublishAsync(subj string, data []byte) error
  188. }
  189. // jsPublisher adapts a nats.JetStreamContext to the natsPublisher interface.
  190. type jsPublisher struct {
  191. js nats.JetStreamContext
  192. }
  193. func (j *jsPublisher) PublishAsync(subj string, data []byte) error {
  194. _, err := j.js.PublishAsync(subj, data)
  195. return err
  196. }
  197. // newNatsPublisher is the constructor used by main.
  198. func newNatsPublisher(js nats.JetStreamContext) natsPublisher {
  199. return &jsPublisher{js: js}
  200. }