http.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. // HTTP POST handler for ingestd. Implements the seven protection
  2. // layers from SPEC §22 in order:
  3. //
  4. // 1. payload-size cap
  5. // 2. (per-IP) — M1: deferred to M5 with the WS path
  6. // 3. per-source token bucket
  7. // 4. per-company token bucket
  8. // 5. schema validate
  9. // 6. (broker CB) — M9
  10. // 7. (quarantine) — M9
  11. //
  12. // Auth: Stripe-style HMAC-SHA256 in header `X-BA-Signature: t=<ts>,v1=<hex>`.
  13. // The source's secret is keyed by (company_id, source_id) — for M0
  14. // the secret is fetched from the source table; for M0 the simplest
  15. // impl is a single env var per source, but we go straight to the
  16. // DB lookup so M2 doesn't have to change this path.
  17. package main
  18. import (
  19. "crypto/hmac"
  20. "crypto/sha256"
  21. "crypto/subtle"
  22. "encoding/hex"
  23. "encoding/json"
  24. "errors"
  25. "fmt"
  26. "io"
  27. "log/slog"
  28. "net/http"
  29. "os"
  30. "strconv"
  31. "strings"
  32. "time"
  33. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  34. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  35. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  36. "git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
  37. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  38. "git3.techno-world.net/lrosales/broad-announce/internal/ratelimit"
  39. "github.com/nats-io/nats.go"
  40. )
  41. // httpDeps is what the handler needs. Injected so tests can swap.
  42. type httpDeps struct {
  43. Logger *slog.Logger
  44. Metrics *observability.IngestdMetrics
  45. Limiter *ratelimit.Limiter
  46. Deduper *dedupe.Deduper
  47. JetStream natsPublisher
  48. MaxBytes int
  49. // For M0 we skip the DB lookup and read sources from a small
  50. // static map. M2 replaces this with a real store.
  51. Sources map[string]SourceConfig
  52. // Now is overridable in tests.
  53. Now func() time.Time
  54. }
  55. // SourceConfig is what we need to know about a source to authenticate
  56. // + rate-limit it. The full Sources row has more fields; this is the
  57. // hot-path subset.
  58. type SourceConfig struct {
  59. CompanyID string
  60. HMACSecret []byte
  61. RateLimitPerSec int
  62. AllowedTargets []string // M2
  63. }
  64. // natsPublisher is the minimal NATS interface the handler uses.
  65. type natsPublisher interface {
  66. PublishAsync(subj string, data []byte) error
  67. }
  68. // AcceptResponse is the JSON body returned on 202.
  69. type AcceptResponse struct {
  70. AlertID string `json:"alert_id"`
  71. DedupeCount uint32 `json:"dedupe_count"`
  72. ReceivedAt string `json:"received_at"`
  73. }
  74. // RegisterRoutes wires the ingestd HTTP routes onto the given mux.
  75. func RegisterRoutes(mux *http.ServeMux, d *httpDeps) {
  76. mux.HandleFunc("POST /v1/ingest", d.handleIngest)
  77. }
  78. // handleIngest is the M0 HTTP POST endpoint. Auth, validate, dedupe,
  79. // publish.
  80. func (d *httpDeps) handleIngest(w http.ResponseWriter, r *http.Request) {
  81. ctx := r.Context()
  82. now := d.now()
  83. // 1. Payload-size cap. We use MaxBytesReader so a streaming
  84. // client can't lie about Content-Length and try to OOM us.
  85. r.Body = http.MaxBytesReader(w, r.Body, int64(d.MaxBytes))
  86. body, err := io.ReadAll(r.Body)
  87. if err != nil {
  88. var maxErr *http.MaxBytesError
  89. if errors.As(err, &maxErr) {
  90. d.Metrics.AlertsReceived.WithLabelValues("payload_too_large").Inc()
  91. writeErr(w, http.StatusRequestEntityTooLarge, "payload_too_large", "")
  92. return
  93. }
  94. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  95. writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
  96. return
  97. }
  98. _ = r.Body.Close()
  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. writeErr(w, http.StatusBadRequest, "invalid_json", err.Error())
  104. return
  105. }
  106. if err := a.Validate(); err != nil {
  107. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  108. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  109. return
  110. }
  111. // Look up source. M0: in-memory map. M2: DB.
  112. src, ok := d.Sources[a.CompanyID+":"+a.SourceID]
  113. if !ok {
  114. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  115. writeErr(w, http.StatusUnauthorized, "unknown_source",
  116. fmt.Sprintf("no such source %s/%s", a.CompanyID, a.SourceID))
  117. return
  118. }
  119. // Auth. Stripe-style: X-BA-Signature: t=<unix>,v1=<hex>
  120. if !verifyHMAC(r.Header.Get("X-BA-Signature"), src.HMACSecret, body, now) {
  121. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  122. writeErr(w, http.StatusUnauthorized, "bad_signature", "")
  123. return
  124. }
  125. // 3. Per-source rate limit.
  126. if ok, ttl, err := d.Limiter.Allow(ctx, "source:"+a.CompanyID+":"+a.SourceID, src.RateLimitPerSec); err != nil {
  127. // Fail open on Redis errors — we don't want a Redis blip
  128. // to take down ingestion. Log loud, count it.
  129. d.Logger.Warn("ratelimit redis error (failing open)", "err", err, "scope", "source")
  130. } else if !ok {
  131. d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
  132. d.Metrics.RateLimitHits.WithLabelValues("source").Inc()
  133. w.Header().Set("Retry-After", strconv.Itoa(int(ttl.Seconds())))
  134. writeErr(w, http.StatusTooManyRequests, "rate_limited_source", "")
  135. return
  136. }
  137. // 4. Per-company rate limit (cap from config; M2 will pull from DB).
  138. // For M0 we just use a constant default; replace with config load
  139. // once that lands.
  140. if ok, ttl, _ := d.Limiter.Allow(ctx, "company:"+a.CompanyID, 10_000); !ok {
  141. d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
  142. d.Metrics.RateLimitHits.WithLabelValues("company").Inc()
  143. w.Header().Set("Retry-After", strconv.Itoa(int(ttl.Seconds())))
  144. writeErr(w, http.StatusTooManyRequests, "rate_limited_company", "")
  145. return
  146. }
  147. // 5b. Dedupe.
  148. isNew, count, err := d.Deduper.Check(ctx, a.SourceID, a.DedupeKey)
  149. if err != nil {
  150. // Fail open on dedupe errors too.
  151. d.Logger.Warn("dedupe redis error (failing open)", "err", err)
  152. isNew, count = true, 1
  153. }
  154. // Stamp server-side fields.
  155. a.ID = alert.NewID()
  156. a.ReceivedAt = now.UTC()
  157. a.DedupeCount = count
  158. // Publish to NATS.
  159. subject := broker.AlertsSubject(a.CompanyID)
  160. payload, err := json.Marshal(a)
  161. if err != nil {
  162. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  163. writeErr(w, http.StatusInternalServerError, "marshal_failed", err.Error())
  164. return
  165. }
  166. start := time.Now()
  167. if err := d.JetStream.PublishAsync(subject, payload); err != nil {
  168. // Circuit breaker (M9) wraps this. For M0 we just fail loud.
  169. d.Metrics.AlertsReceived.WithLabelValues("circuit_open").Inc()
  170. writeErr(w, http.StatusServiceUnavailable, "broker_unavailable", err.Error())
  171. return
  172. }
  173. d.Metrics.PublishLatency.Observe(time.Since(start).Seconds())
  174. d.Metrics.PayloadBytes.Observe(float64(len(payload)))
  175. if isNew {
  176. d.Metrics.AlertsReceived.WithLabelValues("accepted").Inc()
  177. } else {
  178. d.Metrics.AlertsReceived.WithLabelValues("deduped").Inc()
  179. }
  180. w.Header().Set("Content-Type", "application/json")
  181. w.WriteHeader(http.StatusAccepted)
  182. _ = json.NewEncoder(w).Encode(AcceptResponse{
  183. AlertID: a.ID,
  184. DedupeCount: count,
  185. ReceivedAt: a.ReceivedAt.Format(time.RFC3339Nano),
  186. })
  187. d.Logger.Info("alert accepted",
  188. "alert_id", a.ID,
  189. "company_id", a.CompanyID,
  190. "source_id", a.SourceID,
  191. "severity", string(a.Severity),
  192. "dedupe_count", count,
  193. )
  194. }
  195. // now returns the testable clock.
  196. func (d *httpDeps) now() time.Time {
  197. if d.Now != nil {
  198. return d.Now()
  199. }
  200. return time.Now()
  201. }
  202. // verifyHMAC parses `X-BA-Signature: t=<unix>,v1=<hex>` and checks
  203. // HMAC-SHA256(secret, "<unix>.<body>") == hex. Replay window: 5 min.
  204. func verifyHMAC(header string, secret, body []byte, now time.Time) bool {
  205. if header == "" || len(secret) == 0 {
  206. return false
  207. }
  208. var tsStr, sigHex string
  209. for _, part := range strings.Split(header, ",") {
  210. kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
  211. if len(kv) != 2 {
  212. continue
  213. }
  214. switch kv[0] {
  215. case "t":
  216. tsStr = kv[1]
  217. case "v1":
  218. sigHex = kv[1]
  219. }
  220. }
  221. if tsStr == "" || sigHex == "" {
  222. return false
  223. }
  224. tsInt, err := strconv.ParseInt(tsStr, 10, 64)
  225. if err != nil {
  226. return false
  227. }
  228. ts := time.Unix(tsInt, 0)
  229. if abs(now.Sub(ts)) > 5*time.Minute {
  230. return false
  231. }
  232. mac := hmac.New(sha256.New, secret)
  233. mac.Write([]byte(tsStr))
  234. mac.Write([]byte("."))
  235. mac.Write(body)
  236. expected := mac.Sum(nil)
  237. got, err := hex.DecodeString(sigHex)
  238. if err != nil {
  239. return false
  240. }
  241. return subtle.ConstantTimeCompare(expected, got) == 1
  242. }
  243. func abs(d time.Duration) time.Duration {
  244. if d < 0 {
  245. return -d
  246. }
  247. return d
  248. }
  249. func writeErr(w http.ResponseWriter, code int, reason, detail string) {
  250. w.Header().Set("Content-Type", "application/json")
  251. w.WriteHeader(code)
  252. body := map[string]string{"error": reason}
  253. if detail != "" {
  254. body["detail"] = detail
  255. }
  256. _ = json.NewEncoder(w).Encode(body)
  257. }
  258. // jsPublisher adapts a nats.JetStreamContext to the natsPublisher interface.
  259. type jsPublisher struct {
  260. js nats.JetStreamContext
  261. }
  262. func (j *jsPublisher) PublishAsync(subj string, data []byte) error {
  263. _, err := j.js.PublishAsync(subj, data)
  264. return err
  265. }
  266. // newNatsPublisher is the constructor used by main.
  267. func newNatsPublisher(js nats.JetStreamContext) natsPublisher {
  268. return &jsPublisher{js: js}
  269. }
  270. // loadSourcesFromEnv parses BA_INGESTD_SOURCES as a comma-separated
  271. // list of company_id:source_id:secret triples. M0 dev-mode only;
  272. // M2 swaps this for a Postgres lookup.
  273. func loadSourcesFromEnv(logger *slog.Logger) map[string]SourceConfig {
  274. out := map[string]SourceConfig{}
  275. raw := os.Getenv("BA_INGESTD_SOURCES")
  276. if raw == "" {
  277. logger.Warn("no BA_INGESTD_SOURCES configured; ingestd will reject all sources")
  278. return out
  279. }
  280. for _, triple := range strings.Split(raw, ",") {
  281. parts := strings.SplitN(triple, ":", 3)
  282. if len(parts) != 3 {
  283. logger.Warn("bad BA_INGESTD_SOURCES entry, expected company:source:secret", "entry", triple)
  284. continue
  285. }
  286. key := parts[0] + ":" + parts[1]
  287. out[key] = SourceConfig{
  288. CompanyID: parts[0],
  289. HMACSecret: []byte(parts[2]),
  290. RateLimitPerSec: config.GetInt("BA_INGESTD_RATE_LIMIT_PER_SOURCE", 100),
  291. }
  292. }
  293. logger.Info("loaded sources from env", "count", len(out))
  294. return out
  295. }