pipeline.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. // Package pipeline is the shared alert-processing engine used by all
  2. // ingestd transports (HTTP POST, WebSocket, MQTT, gRPC).
  3. //
  4. // It implements the SPEC §22 protection chain in order:
  5. //
  6. // 1. payload-size cap (caller enforces)
  7. // 2. quarantine check (M9 layer 7 — per-source error ban)
  8. // 3. per-source rate limit
  9. // 4. per-company rate limit
  10. // 5. schema validate + parse
  11. // 6. HMAC verify (transport-specific — caller passes sig)
  12. // 7. dedupe (Redis sliding window)
  13. // 8. publish to NATS JetStream (M9 layer 6 — circuit breaker)
  14. //
  15. // The function is pure (no global state, no transport types). It returns
  16. // Result{Accepted/Rejected + reason + alert_id + dedupe_count} so the
  17. // caller maps it to its own transport-level response shape.
  18. package pipeline
  19. import (
  20. "context"
  21. "crypto/hmac"
  22. "crypto/sha256"
  23. "crypto/subtle"
  24. "encoding/hex"
  25. "encoding/json"
  26. "errors"
  27. "fmt"
  28. "log/slog"
  29. "strconv"
  30. "strings"
  31. "time"
  32. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  33. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  34. "git3.techno-world.net/lrosales/broad-announce/internal/circuitbreaker"
  35. "git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
  36. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  37. "git3.techno-world.net/lrosales/broad-announce/internal/quarantine"
  38. "git3.techno-world.net/lrosales/broad-announce/internal/ratelimit"
  39. "git3.techno-world.net/lrosales/broad-announce/internal/tailhub"
  40. "github.com/nats-io/nats.go"
  41. )
  42. // SourceConfig holds the per-source authentication and rate-limit parameters
  43. // needed by the pipeline. It is the canonical definition; callers must
  44. // populate the Sources map with entries keyed by "company_id:source_id".
  45. type SourceConfig struct {
  46. CompanyID string
  47. SourceID string
  48. HMACSecret []byte // may be empty for transports that don't use HMAC
  49. RateLimitPerSec int
  50. AllowedTargets []string // M2: allowed routing targets (pipeline ignores; caller enforces)
  51. }
  52. // Result is the outcome of a Process call. The caller maps it to
  53. // its transport's response shape (HTTP status, gRPC status, etc.).
  54. type Result struct {
  55. // Accepted is true if the alert passed all checks and was published to NATS.
  56. Accepted bool
  57. // AlertID is the server-assigned id (empty on all error paths).
  58. AlertID string
  59. // DedupeCount is the dedupe hit count: 1 = first arrival in window,
  60. // >1 = collapsed burst.
  61. DedupeCount uint32
  62. // IsNew is true for the first alert in a dedupe window.
  63. IsNew bool
  64. // RejectReason is one of:
  65. // "invalid_json", "invalid", "unknown_source", "bad_signature",
  66. // "quarantined", "rate_limited_source", "rate_limited_company",
  67. // "marshal_failed", "broker_unavailable", "circuit_open"
  68. RejectReason string
  69. // HTTPStatus is the suggested HTTP status code (202 on accept, 4xx/5xx on reject).
  70. HTTPStatus int
  71. // Detail is free-form context for logging or error bodies.
  72. Detail string
  73. }
  74. // Accept is the canonical "ok" result.
  75. func Accept(id string, count uint32, isNew bool) Result {
  76. return Result{Accepted: true, AlertID: id, DedupeCount: count, IsNew: isNew, HTTPStatus: 202}
  77. }
  78. // Reject is the canonical "no" result.
  79. func Reject(reason string, status int, detail string) Result {
  80. return Result{RejectReason: reason, HTTPStatus: status, Detail: detail}
  81. }
  82. // Deps is the dependency set for the processing pipeline.
  83. // All transports (HTTP, MQTT, WS, gRPC) construct one of these and call Deps.Process.
  84. type Deps struct {
  85. Logger *slog.Logger
  86. Metrics *observability.IngestdMetrics
  87. Limiter *ratelimit.Limiter
  88. Deduper *dedupe.Deduper
  89. // JetStream is the NATS JetStream publisher.
  90. JetStream natsPublisher
  91. // Sources is the (company_id, source_id) → SourceConfig map.
  92. // M0 reads from env; M2 reads from Postgres.
  93. Sources map[string]SourceConfig
  94. // CompanyRatePerSec is the default per-company rate limit (backstop).
  95. CompanyRatePerSec int
  96. // Tail is the M5 live-tail hub. nil is fine (tests don't need it).
  97. Tail *tailhub.Hub
  98. // Transport is the label used in structured log lines
  99. // ("http" | "mqtt" | "ws" | "grpc").
  100. Transport string
  101. // NowFunc is overridable in tests.
  102. NowFunc func() time.Time
  103. // MaxSeen is the M6 per-source monotonic max tracker for dedupe_count.
  104. // Owned here so all transports share the same in-process state.
  105. MaxSeen *observability.MaxSeen
  106. // CircuitBreaker wraps the NATS publish call (M9 layer 6). Nil = no CB.
  107. CircuitBreaker *circuitbreaker.Breaker
  108. // Quarantine is the M9 layer-7 per-source error-rate limiter. Nil = no quarantine.
  109. Quarantine *quarantine.Manager
  110. }
  111. // Process runs the full SPEC §22 protection chain on one alert body.
  112. // sig is the transport-specific auth token. For HTTP: the HMAC header value.
  113. // For gRPC (which authenticates via API key metadata before entering the pipeline):
  114. // pass an empty string — the pipeline skips HMAC verification.
  115. func (d *Deps) Process(ctx context.Context, body []byte, sig string) Result {
  116. now := d.Now()
  117. // 5. Parse + validate.
  118. var a alert.Alert
  119. if err := json.Unmarshal(body, &a); err != nil {
  120. d.Metrics.AlertsReceived.WithLabelValues(d.Transport, "invalid").Inc()
  121. return Reject("invalid_json", 400, err.Error())
  122. }
  123. if err := a.Validate(); err != nil {
  124. d.Metrics.AlertsReceived.WithLabelValues(d.Transport, "invalid").Inc()
  125. return Reject("invalid", 400, err.Error())
  126. }
  127. // Source lookup.
  128. src, ok := d.Sources[a.CompanyID+":"+a.SourceID]
  129. if !ok {
  130. d.Metrics.AlertsReceived.WithLabelValues(d.Transport, "invalid").Inc()
  131. return Reject("unknown_source", 401,
  132. fmt.Sprintf("no such source %s/%s", a.CompanyID, a.SourceID))
  133. }
  134. // 2. Quarantine check (M9 layer 7). Before we spend any CPU.
  135. if d.Quarantine != nil {
  136. if banned, remaining, err := d.Quarantine.IsBanned(ctx, a.SourceID); err == nil && banned {
  137. d.Metrics.AlertsReceived.WithLabelValues(d.Transport, "quarantined").Inc()
  138. d.Logger.Warn("source quarantined",
  139. "source_id", a.SourceID,
  140. "company_id", a.CompanyID,
  141. "remaining", remaining,
  142. )
  143. return Reject("quarantined", 429,
  144. fmt.Sprintf("source quarantined for %v; retry after", remaining.Round(time.Second)))
  145. }
  146. }
  147. // 6. Auth (transport-specific; gRPC skips by passing "").
  148. if sig != "" && !verifyHMAC(sig, src.HMACSecret, body, now) {
  149. d.Metrics.AlertsReceived.WithLabelValues(d.Transport, "invalid").Inc()
  150. return Reject("bad_signature", 401, "")
  151. }
  152. // M9 quarantine hit tracking. Every rejection after source-confirmation
  153. // gets recorded so the source's error rate climbs.
  154. var hitRecorded bool
  155. defer func() {
  156. if !hitRecorded && d.Quarantine != nil {
  157. _ = d.Quarantine.RecordHit(context.Background(), a.SourceID)
  158. }
  159. }()
  160. recordHit := func() {
  161. if d.Quarantine != nil && !hitRecorded {
  162. hitRecorded = true
  163. _ = d.Quarantine.RecordHit(context.Background(), a.SourceID)
  164. }
  165. }
  166. // 7. Dedupe BEFORE rate limit (M6). Duplicates don't burn rate-limit tokens.
  167. isNew, count, err := d.Deduper.Check(ctx, a.SourceID, a.DedupeKey)
  168. if err != nil {
  169. d.Logger.Warn("dedupe redis error (failing open)", "err", err)
  170. isNew, count = true, 1
  171. }
  172. if !isNew {
  173. d.Metrics.DedupeCollapsed.WithLabelValues(a.SourceID).Inc()
  174. d.MaxSeen.RecordAndExport(a.SourceID, count,
  175. func(s string, v float64) {
  176. d.Metrics.DedupeCountMax.WithLabelValues(s).Set(v)
  177. })
  178. }
  179. // 3. Per-source rate limit (new alerts only).
  180. if isNew {
  181. if ok, ttl, err := d.Limiter.Allow(ctx, "source:"+a.CompanyID+":"+a.SourceID, src.RateLimitPerSec); err != nil {
  182. d.Logger.Warn("ratelimit redis error (failing open)", "err", err, "scope", "source")
  183. } else if !ok {
  184. d.Metrics.AlertsReceived.WithLabelValues(d.Transport, "rate_limited").Inc()
  185. d.Metrics.RateLimitHits.WithLabelValues("source").Inc()
  186. recordHit()
  187. return Reject("rate_limited_source", 429, strconv.Itoa(int(ttl.Seconds())))
  188. }
  189. }
  190. // 4. Per-company rate limit (new alerts only).
  191. if isNew {
  192. if ok, ttl, _ := d.Limiter.Allow(ctx, "company:"+a.CompanyID, d.CompanyRatePerSec); !ok {
  193. d.Metrics.AlertsReceived.WithLabelValues(d.Transport, "rate_limited").Inc()
  194. d.Metrics.RateLimitHits.WithLabelValues("company").Inc()
  195. recordHit()
  196. return Reject("rate_limited_company", 429, strconv.Itoa(int(ttl.Seconds())))
  197. }
  198. }
  199. // Stamp server-side fields.
  200. a.ID = alert.NewID()
  201. a.ReceivedAt = now.UTC()
  202. a.DedupeCount = count
  203. // 8. Publish to NATS JetStream (M9 layer 6 circuit breaker wraps this).
  204. subject := broker.AlertsSubject(a.CompanyID)
  205. payload, err := json.Marshal(a)
  206. if err != nil {
  207. d.Metrics.AlertsReceived.WithLabelValues(d.Transport, "invalid").Inc()
  208. return Reject("marshal_failed", 500, err.Error())
  209. }
  210. start := time.Now()
  211. var publishErr error
  212. if d.CircuitBreaker != nil {
  213. // M11 fix: PublishAsync returns a future immediately. The CB wraps
  214. // the *submission* (not the ack) so a JetStream stall still surfaces
  215. // to the circuit breaker within the configured timeout. The actual
  216. // ack is observed in a fire-and-forget goroutine.
  217. var fut nats.PubAckFuture
  218. publishErr = d.CircuitBreaker.Do(ctx, func() error {
  219. f, err := d.JetStream.PublishAsync(subject, payload)
  220. if err != nil {
  221. // F2: submission-level failure (queue full, JS stopped, etc.)
  222. if d.Metrics != nil {
  223. d.Metrics.NATSPublishTotal.WithLabelValues("error").Inc()
  224. }
  225. return err
  226. }
  227. fut = f
  228. return nil
  229. })
  230. if publishErr == nil && fut != nil {
  231. go observeAsyncAck(fut, d, a.SourceID, subject, start)
  232. }
  233. } else {
  234. fut, err := d.JetStream.PublishAsync(subject, payload)
  235. if err != nil {
  236. // F2: submission-level failure (no CB path).
  237. if d.Metrics != nil {
  238. d.Metrics.NATSPublishTotal.WithLabelValues("error").Inc()
  239. }
  240. publishErr = err
  241. } else if fut != nil {
  242. go observeAsyncAck(fut, d, a.SourceID, subject, start)
  243. }
  244. }
  245. if publishErr != nil {
  246. if errors.Is(publishErr, circuitbreaker.ErrCircuitOpen) {
  247. d.Metrics.AlertsReceived.WithLabelValues(d.Transport, "circuit_open").Inc()
  248. d.Metrics.CBState.WithLabelValues("nats").Set(circuitbreaker.StateOpen)
  249. d.Logger.Warn("circuit breaker open",
  250. "subject", subject,
  251. "alert_id", a.ID,
  252. "company_id", a.CompanyID,
  253. )
  254. recordHit()
  255. return Reject("circuit_open", 503, "broker circuit breaker open")
  256. }
  257. d.Metrics.AlertsReceived.WithLabelValues(d.Transport, "broker_unavailable").Inc()
  258. d.Logger.Error("nats publish", "err", publishErr, "subject", subject)
  259. recordHit()
  260. return Reject("broker_unavailable", 503, publishErr.Error())
  261. }
  262. d.Metrics.PublishLatency.WithLabelValues(a.SourceID).Observe(time.Since(start).Seconds())
  263. d.Metrics.PayloadBytes.Observe(float64(len(payload)))
  264. if isNew {
  265. d.Metrics.AlertsReceived.WithLabelValues(d.Transport, "accepted").Inc()
  266. } else {
  267. d.Metrics.AlertsReceived.WithLabelValues(d.Transport, "deduped").Inc()
  268. }
  269. d.Logger.Info("alert accepted",
  270. "alert_id", a.ID,
  271. "company_id", a.CompanyID,
  272. "source_id", a.SourceID,
  273. "severity", string(a.Severity),
  274. "transport", d.Transport,
  275. "dedupe_count", count,
  276. )
  277. // M5: fan out to live-tail hub (if configured). Best-effort, never blocks.
  278. if d.Tail != nil {
  279. ev := tailhub.FromAlert(&a, d.Transport)
  280. d.Tail.Publish(ev)
  281. }
  282. hitRecorded = true // mark accepted so defer doesn't record a spurious hit
  283. return Accept(a.ID, count, isNew)
  284. }
  285. // Now returns the current time, using d.NowFunc if set.
  286. func (d *Deps) Now() time.Time {
  287. if d.NowFunc != nil {
  288. return d.NowFunc()
  289. }
  290. return time.Now()
  291. }
  292. // natsPublisher is the minimal NATS interface the pipeline needs.
  293. type natsPublisher interface {
  294. Publish(subj string, data []byte) error
  295. // PublishAsync submits to JetStream's internal queue and returns a
  296. // future that resolves when the broker acks persistence. Callers
  297. // observe the future asynchronously to avoid blocking the hot path.
  298. PublishAsync(subj string, data []byte) (nats.PubAckFuture, error)
  299. }
  300. // jsPublisher adapts nats.JetStreamContext to natsPublisher.
  301. type jsPublisher struct{ js nats.JetStreamContext }
  302. func (j *jsPublisher) Publish(subj string, data []byte) error {
  303. _, err := j.js.Publish(subj, data)
  304. return err
  305. }
  306. func (j *jsPublisher) PublishAsync(subj string, data []byte) (nats.PubAckFuture, error) {
  307. return j.js.PublishAsync(subj, data)
  308. }
  309. // NewNatsPublisher constructs a natsPublisher from a JetStream context.
  310. func NewNatsPublisher(js nats.JetStreamContext) natsPublisher {
  311. return &jsPublisher{js: js}
  312. }
  313. // observeAsyncAck blocks on the PubAckFuture and records publish latency
  314. // or a warn-level log on failure. Runs in its own goroutine so the hot path
  315. // returns immediately. Latency is measured from start to broker ack.
  316. func observeAsyncAck(fut nats.PubAckFuture, d *Deps, sourceID, subject string, sentAt time.Time) {
  317. if fut == nil {
  318. return
  319. }
  320. select {
  321. case <-fut.Ok():
  322. // F2: broker accepted and persisted the message.
  323. if d != nil && d.Metrics != nil {
  324. d.Metrics.PublishLatency.WithLabelValues(sourceID).Observe(time.Since(sentAt).Seconds())
  325. d.Metrics.NATSPublishTotal.WithLabelValues("ok").Inc()
  326. }
  327. case err := <-fut.Err():
  328. // F2: broker rejected / timed out. This is the failure mode that
  329. // the M11 NATS investigation missed (system looked healthy on
  330. // the receive metric while publishes were silently failing).
  331. if d != nil && d.Metrics != nil {
  332. d.Metrics.NATSPublishTotal.WithLabelValues("error").Inc()
  333. }
  334. if d != nil && d.Logger != nil {
  335. d.Logger.Warn("async publish failed", "subject", subject, "source_id", sourceID, "err", err)
  336. }
  337. }
  338. }
  339. // verifyHMAC parses `X-BA-Signature: t=<unix>,v1=<hex>` and checks
  340. // HMAC-SHA256(secret, "<unix>.<body>") == hex. Replay window: 5 min.
  341. // Exported so HTTP handlers can call it directly; gRPC passes sig="".
  342. func verifyHMAC(header string, secret, body []byte, now time.Time) bool {
  343. if header == "" || len(secret) == 0 {
  344. return false
  345. }
  346. var tsStr, sigHex string
  347. for _, part := range strings.Split(header, ",") {
  348. kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
  349. if len(kv) != 2 {
  350. continue
  351. }
  352. switch kv[0] {
  353. case "t":
  354. tsStr = kv[1]
  355. case "v1":
  356. sigHex = kv[1]
  357. }
  358. }
  359. if tsStr == "" || sigHex == "" {
  360. return false
  361. }
  362. tsInt, err := strconv.ParseInt(tsStr, 10, 64)
  363. if err != nil {
  364. return false
  365. }
  366. ts := time.Unix(tsInt, 0)
  367. if abs(now.Sub(ts)) > 5*time.Minute {
  368. return false
  369. }
  370. mac := hmac.New(sha256.New, secret)
  371. mac.Write([]byte(tsStr))
  372. mac.Write([]byte("."))
  373. mac.Write(body)
  374. expected := mac.Sum(nil)
  375. got, err := hex.DecodeString(sigHex)
  376. if err != nil {
  377. return false
  378. }
  379. return subtle.ConstantTimeCompare(expected, got) == 1
  380. }
  381. func abs(d time.Duration) time.Duration {
  382. if d < 0 {
  383. return -d
  384. }
  385. return d
  386. }