pipeline.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  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. HMACSecret []byte // may be empty for transports that don't use HMAC
  48. RateLimitPerSec int
  49. AllowedTargets []string // M2: allowed routing targets (pipeline ignores; caller enforces)
  50. }
  51. // Result is the outcome of a Process call. The caller maps it to
  52. // its transport's response shape (HTTP status, gRPC status, etc.).
  53. type Result struct {
  54. // Accepted is true if the alert passed all checks and was published to NATS.
  55. Accepted bool
  56. // AlertID is the server-assigned id (empty on all error paths).
  57. AlertID string
  58. // DedupeCount is the dedupe hit count: 1 = first arrival in window,
  59. // >1 = collapsed burst.
  60. DedupeCount uint32
  61. // IsNew is true for the first alert in a dedupe window.
  62. IsNew bool
  63. // RejectReason is one of:
  64. // "invalid_json", "invalid", "unknown_source", "bad_signature",
  65. // "quarantined", "rate_limited_source", "rate_limited_company",
  66. // "marshal_failed", "broker_unavailable", "circuit_open"
  67. RejectReason string
  68. // HTTPStatus is the suggested HTTP status code (202 on accept, 4xx/5xx on reject).
  69. HTTPStatus int
  70. // Detail is free-form context for logging or error bodies.
  71. Detail string
  72. }
  73. // Accept is the canonical "ok" result.
  74. func Accept(id string, count uint32, isNew bool) Result {
  75. return Result{Accepted: true, AlertID: id, DedupeCount: count, IsNew: isNew, HTTPStatus: 202}
  76. }
  77. // Reject is the canonical "no" result.
  78. func Reject(reason string, status int, detail string) Result {
  79. return Result{RejectReason: reason, HTTPStatus: status, Detail: detail}
  80. }
  81. // Deps is the dependency set for the processing pipeline.
  82. // All transports (HTTP, MQTT, WS, gRPC) construct one of these and call Deps.Process.
  83. type Deps struct {
  84. Logger *slog.Logger
  85. Metrics *observability.IngestdMetrics
  86. Limiter *ratelimit.Limiter
  87. Deduper *dedupe.Deduper
  88. // JetStream is the NATS JetStream publisher.
  89. JetStream natsPublisher
  90. // Sources is the (company_id, source_id) → SourceConfig map.
  91. // M0 reads from env; M2 reads from Postgres.
  92. Sources map[string]SourceConfig
  93. // CompanyRatePerSec is the default per-company rate limit (backstop).
  94. CompanyRatePerSec int
  95. // Tail is the M5 live-tail hub. nil is fine (tests don't need it).
  96. Tail *tailhub.Hub
  97. // Transport is the label used in structured log lines
  98. // ("http" | "mqtt" | "ws" | "grpc").
  99. Transport string
  100. // NowFunc is overridable in tests.
  101. NowFunc func() time.Time
  102. // MaxSeen is the M6 per-source monotonic max tracker for dedupe_count.
  103. // Owned here so all transports share the same in-process state.
  104. MaxSeen *observability.MaxSeen
  105. // CircuitBreaker wraps the NATS publish call (M9 layer 6). Nil = no CB.
  106. CircuitBreaker *circuitbreaker.Breaker
  107. // Quarantine is the M9 layer-7 per-source error-rate limiter. Nil = no quarantine.
  108. Quarantine *quarantine.Manager
  109. }
  110. // Process runs the full SPEC §22 protection chain on one alert body.
  111. // sig is the transport-specific auth token. For HTTP: the HMAC header value.
  112. // For gRPC (which authenticates via API key metadata before entering the pipeline):
  113. // pass an empty string — the pipeline skips HMAC verification.
  114. func (d *Deps) Process(ctx context.Context, body []byte, sig string) Result {
  115. now := d.Now()
  116. // 5. Parse + validate.
  117. var a alert.Alert
  118. if err := json.Unmarshal(body, &a); err != nil {
  119. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  120. return Reject("invalid_json", 400, err.Error())
  121. }
  122. if err := a.Validate(); err != nil {
  123. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  124. return Reject("invalid", 400, err.Error())
  125. }
  126. // Source lookup.
  127. src, ok := d.Sources[a.CompanyID+":"+a.SourceID]
  128. if !ok {
  129. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  130. return Reject("unknown_source", 401,
  131. fmt.Sprintf("no such source %s/%s", a.CompanyID, a.SourceID))
  132. }
  133. // 2. Quarantine check (M9 layer 7). Before we spend any CPU.
  134. if d.Quarantine != nil {
  135. if banned, remaining, err := d.Quarantine.IsBanned(ctx, a.SourceID); err == nil && banned {
  136. d.Metrics.AlertsReceived.WithLabelValues("quarantined").Inc()
  137. d.Logger.Warn("source quarantined",
  138. "source_id", a.SourceID,
  139. "company_id", a.CompanyID,
  140. "remaining", remaining,
  141. )
  142. return Reject("quarantined", 429,
  143. fmt.Sprintf("source quarantined for %v; retry after", remaining.Round(time.Second)))
  144. }
  145. }
  146. // 6. Auth (transport-specific; gRPC skips by passing "").
  147. if sig != "" && !verifyHMAC(sig, src.HMACSecret, body, now) {
  148. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  149. return Reject("bad_signature", 401, "")
  150. }
  151. // M9 quarantine hit tracking. Every rejection after source-confirmation
  152. // gets recorded so the source's error rate climbs.
  153. var hitRecorded bool
  154. defer func() {
  155. if !hitRecorded && d.Quarantine != nil {
  156. _ = d.Quarantine.RecordHit(context.Background(), a.SourceID)
  157. }
  158. }()
  159. recordHit := func() {
  160. if d.Quarantine != nil && !hitRecorded {
  161. hitRecorded = true
  162. _ = d.Quarantine.RecordHit(context.Background(), a.SourceID)
  163. }
  164. }
  165. // 7. Dedupe BEFORE rate limit (M6). Duplicates don't burn rate-limit tokens.
  166. isNew, count, err := d.Deduper.Check(ctx, a.SourceID, a.DedupeKey)
  167. if err != nil {
  168. d.Logger.Warn("dedupe redis error (failing open)", "err", err)
  169. isNew, count = true, 1
  170. }
  171. if !isNew {
  172. d.Metrics.DedupeCollapsed.WithLabelValues(a.SourceID).Inc()
  173. d.MaxSeen.RecordAndExport(a.SourceID, count,
  174. func(s string, v float64) {
  175. d.Metrics.DedupeCountMax.WithLabelValues(s).Set(v)
  176. })
  177. }
  178. // 3. Per-source rate limit (new alerts only).
  179. if isNew {
  180. if ok, ttl, err := d.Limiter.Allow(ctx, "source:"+a.CompanyID+":"+a.SourceID, src.RateLimitPerSec); err != nil {
  181. d.Logger.Warn("ratelimit redis error (failing open)", "err", err, "scope", "source")
  182. } else if !ok {
  183. d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
  184. d.Metrics.RateLimitHits.WithLabelValues("source").Inc()
  185. recordHit()
  186. return Reject("rate_limited_source", 429, strconv.Itoa(int(ttl.Seconds())))
  187. }
  188. }
  189. // 4. Per-company rate limit (new alerts only).
  190. if isNew {
  191. if ok, ttl, _ := d.Limiter.Allow(ctx, "company:"+a.CompanyID, d.CompanyRatePerSec); !ok {
  192. d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
  193. d.Metrics.RateLimitHits.WithLabelValues("company").Inc()
  194. recordHit()
  195. return Reject("rate_limited_company", 429, strconv.Itoa(int(ttl.Seconds())))
  196. }
  197. }
  198. // Stamp server-side fields.
  199. a.ID = alert.NewID()
  200. a.ReceivedAt = now.UTC()
  201. a.DedupeCount = count
  202. // 8. Publish to NATS JetStream (M9 layer 6 circuit breaker wraps this).
  203. subject := broker.AlertsSubject(a.CompanyID)
  204. payload, err := json.Marshal(a)
  205. if err != nil {
  206. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  207. return Reject("marshal_failed", 500, err.Error())
  208. }
  209. start := time.Now()
  210. var publishErr error
  211. if d.CircuitBreaker != nil {
  212. publishErr = d.CircuitBreaker.Do(ctx, func() error {
  213. return d.JetStream.Publish(subject, payload)
  214. })
  215. } else {
  216. publishErr = d.JetStream.Publish(subject, payload)
  217. }
  218. if publishErr != nil {
  219. if errors.Is(publishErr, circuitbreaker.ErrCircuitOpen) {
  220. d.Metrics.AlertsReceived.WithLabelValues("circuit_open").Inc()
  221. d.Metrics.CBState.WithLabelValues("nats").Set(circuitbreaker.StateOpen)
  222. d.Logger.Warn("circuit breaker open",
  223. "subject", subject,
  224. "alert_id", a.ID,
  225. "company_id", a.CompanyID,
  226. )
  227. recordHit()
  228. return Reject("circuit_open", 503, "broker circuit breaker open")
  229. }
  230. d.Metrics.AlertsReceived.WithLabelValues("broker_unavailable").Inc()
  231. d.Logger.Error("nats publish", "err", publishErr, "subject", subject)
  232. recordHit()
  233. return Reject("broker_unavailable", 503, publishErr.Error())
  234. }
  235. d.Metrics.PublishLatency.WithLabelValues(a.SourceID).Observe(time.Since(start).Seconds())
  236. d.Metrics.PayloadBytes.Observe(float64(len(payload)))
  237. if isNew {
  238. d.Metrics.AlertsReceived.WithLabelValues("accepted").Inc()
  239. } else {
  240. d.Metrics.AlertsReceived.WithLabelValues("deduped").Inc()
  241. }
  242. d.Logger.Info("alert accepted",
  243. "alert_id", a.ID,
  244. "company_id", a.CompanyID,
  245. "source_id", a.SourceID,
  246. "severity", string(a.Severity),
  247. "transport", d.Transport,
  248. "dedupe_count", count,
  249. )
  250. // M5: fan out to live-tail hub (if configured). Best-effort, never blocks.
  251. if d.Tail != nil {
  252. ev := tailhub.FromAlert(&a, d.Transport)
  253. d.Tail.Publish(ev)
  254. }
  255. hitRecorded = true // mark accepted so defer doesn't record a spurious hit
  256. return Accept(a.ID, count, isNew)
  257. }
  258. // Now returns the current time, using d.NowFunc if set.
  259. func (d *Deps) Now() time.Time {
  260. if d.NowFunc != nil {
  261. return d.NowFunc()
  262. }
  263. return time.Now()
  264. }
  265. // natsPublisher is the minimal NATS interface the pipeline needs.
  266. type natsPublisher interface {
  267. Publish(subj string, data []byte) error
  268. PublishAsync(subj string, data []byte) error
  269. }
  270. // jsPublisher adapts nats.JetStreamContext to natsPublisher.
  271. type jsPublisher struct{ js nats.JetStreamContext }
  272. func (j *jsPublisher) Publish(subj string, data []byte) error {
  273. _, err := j.js.Publish(subj, data)
  274. return err
  275. }
  276. func (j *jsPublisher) PublishAsync(subj string, data []byte) error {
  277. _, err := j.js.PublishAsync(subj, data)
  278. return err
  279. }
  280. // NewNatsPublisher constructs a natsPublisher from a JetStream context.
  281. func NewNatsPublisher(js nats.JetStreamContext) natsPublisher {
  282. return &jsPublisher{js: js}
  283. }
  284. // verifyHMAC parses `X-BA-Signature: t=<unix>,v1=<hex>` and checks
  285. // HMAC-SHA256(secret, "<unix>.<body>") == hex. Replay window: 5 min.
  286. // Exported so HTTP handlers can call it directly; gRPC passes sig="".
  287. func verifyHMAC(header string, secret, body []byte, now time.Time) bool {
  288. if header == "" || len(secret) == 0 {
  289. return false
  290. }
  291. var tsStr, sigHex string
  292. for _, part := range strings.Split(header, ",") {
  293. kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
  294. if len(kv) != 2 {
  295. continue
  296. }
  297. switch kv[0] {
  298. case "t":
  299. tsStr = kv[1]
  300. case "v1":
  301. sigHex = kv[1]
  302. }
  303. }
  304. if tsStr == "" || sigHex == "" {
  305. return false
  306. }
  307. tsInt, err := strconv.ParseInt(tsStr, 10, 64)
  308. if err != nil {
  309. return false
  310. }
  311. ts := time.Unix(tsInt, 0)
  312. if abs(now.Sub(ts)) > 5*time.Minute {
  313. return false
  314. }
  315. mac := hmac.New(sha256.New, secret)
  316. mac.Write([]byte(tsStr))
  317. mac.Write([]byte("."))
  318. mac.Write(body)
  319. expected := mac.Sum(nil)
  320. got, err := hex.DecodeString(sigHex)
  321. if err != nil {
  322. return false
  323. }
  324. return subtle.ConstantTimeCompare(expected, got) == 1
  325. }
  326. func abs(d time.Duration) time.Duration {
  327. if d < 0 {
  328. return -d
  329. }
  330. return d
  331. }