http.go 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  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. "io"
  26. "log/slog"
  27. "net/http"
  28. "os"
  29. "strconv"
  30. "strings"
  31. "time"
  32. "git3.techno-world.net/lrosales/broad-announce/internal/authd"
  33. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  34. )
  35. // httpDeps is the HTTP-handler-scoped wrapper. It embeds
  36. // processDeps (the shared pipeline used by both HTTP and MQTT)
  37. // and adds the HTTP-only fields (MaxBytes, raw request/response
  38. // types). The body of the handler is a single ProcessAlert call.
  39. type httpDeps struct {
  40. processDeps
  41. MaxBytes int
  42. // Authd is the M13a W2 auth gate. When non-nil, RegisterAdminRoutes
  43. // wraps the handler with RequireAuth so that JWT-authenticated
  44. // users can submit alerts via /v1/admin/ingest. When nil,
  45. // RegisterAdminRoutes is a no-op.
  46. Authd *authd.Authd
  47. }
  48. // AcceptResponse is the JSON body returned on 202.
  49. type AcceptResponse struct {
  50. AlertID string `json:"alert_id"`
  51. DedupeCount uint32 `json:"dedupe_count"`
  52. ReceivedAt string `json:"received_at"`
  53. }
  54. // RegisterRoutes wires the ingestd HTTP routes onto the given mux.
  55. func RegisterRoutes(mux *http.ServeMux, d *httpDeps) {
  56. mux.HandleFunc("POST /v1/ingest", d.handleIngest)
  57. }
  58. // RegisterAdminRoutes wires the JWT-protected routes onto the given
  59. // mux. The same handleIngest handler is used — the auth gate is
  60. // additive: callers still pass an HMAC signature, but the Bearer
  61. // JWT is also required so the alert can be attributed to a user.
  62. //
  63. // When d.Authd is nil (e.g. in tests), this is a no-op so the
  64. // caller doesn't have to plumb the authd service.
  65. func RegisterAdminRoutes(mux *http.ServeMux, d *httpDeps) {
  66. if d == nil || d.Authd == nil {
  67. return
  68. }
  69. mux.Handle("POST /v1/admin/ingest", d.Authd.RequireAuth(http.HandlerFunc(d.handleIngest)))
  70. }
  71. // handleIngest is the M0 HTTP POST endpoint. The body of the
  72. // pipeline is the shared processDeps.ProcessAlert; this handler
  73. // only adds the HTTP-specific bits (MaxBytesReader, header
  74. // signature, response shape).
  75. func (d *httpDeps) handleIngest(w http.ResponseWriter, r *http.Request) {
  76. // 1. Payload-size cap. We use MaxBytesReader so a streaming
  77. // client can't lie about Content-Length and try to OOM us.
  78. r.Body = http.MaxBytesReader(w, r.Body, int64(d.MaxBytes))
  79. body, err := io.ReadAll(r.Body)
  80. if err != nil {
  81. var maxErr *http.MaxBytesError
  82. if errors.As(err, &maxErr) {
  83. d.Metrics.AlertsReceived.WithLabelValues("http", "payload_too_large").Inc()
  84. writeErr(w, http.StatusRequestEntityTooLarge, "payload_too_large", "")
  85. return
  86. }
  87. d.Metrics.AlertsReceived.WithLabelValues("http", "invalid").Inc()
  88. writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
  89. return
  90. }
  91. _ = r.Body.Close()
  92. // If the request came through the JWT gate (POST /v1/admin/ingest),
  93. // the claims are in context. Surface them in the structured log
  94. // so every accepted/rejected alert can be attributed to a user.
  95. if claims := authd.ClaimsFromContext(r.Context()); claims != nil {
  96. d.Logger.Info("admin ingest",
  97. "user_id", claims.UserID,
  98. "tenant_id", claims.TenantID,
  99. "role", claims.Role,
  100. "remote", r.RemoteAddr,
  101. )
  102. }
  103. res := d.ProcessAlert(r.Context(), body, r.Header.Get("X-BA-Signature"))
  104. if !res.Accepted {
  105. // Rate-limit rejections get a Retry-After header.
  106. if res.RejectReason == "rate_limited_source" || res.RejectReason == "rate_limited_company" {
  107. if n, err := strconv.Atoi(res.Detail); err == nil {
  108. w.Header().Set("Retry-After", strconv.Itoa(n))
  109. }
  110. }
  111. writeErr(w, res.HTTPStatus, res.RejectReason, res.Detail)
  112. return
  113. }
  114. w.Header().Set("Content-Type", "application/json")
  115. w.WriteHeader(http.StatusAccepted)
  116. _ = json.NewEncoder(w).Encode(AcceptResponse{
  117. AlertID: res.AlertID,
  118. DedupeCount: res.DedupeCount,
  119. ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
  120. })
  121. }
  122. // now is a thin alias for processDeps.now (kept here so the
  123. // existing tests that call d.now() on httpDeps still work).
  124. func (d *httpDeps) now() time.Time { return d.processDeps.Now() }
  125. // verifyHMAC parses `X-BA-Signature: t=<unix>,v1=<hex>` and checks
  126. // HMAC-SHA256(secret, "<unix>.<body>") == hex. Replay window: 5 min.
  127. func verifyHMAC(header string, secret, body []byte, now time.Time) bool {
  128. if header == "" || len(secret) == 0 {
  129. return false
  130. }
  131. var tsStr, sigHex string
  132. for _, part := range strings.Split(header, ",") {
  133. kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
  134. if len(kv) != 2 {
  135. continue
  136. }
  137. switch kv[0] {
  138. case "t":
  139. tsStr = kv[1]
  140. case "v1":
  141. sigHex = kv[1]
  142. }
  143. }
  144. if tsStr == "" || sigHex == "" {
  145. return false
  146. }
  147. tsInt, err := strconv.ParseInt(tsStr, 10, 64)
  148. if err != nil {
  149. return false
  150. }
  151. ts := time.Unix(tsInt, 0)
  152. if abs(now.Sub(ts)) > 5*time.Minute {
  153. return false
  154. }
  155. mac := hmac.New(sha256.New, secret)
  156. mac.Write([]byte(tsStr))
  157. mac.Write([]byte("."))
  158. mac.Write(body)
  159. expected := mac.Sum(nil)
  160. got, err := hex.DecodeString(sigHex)
  161. if err != nil {
  162. return false
  163. }
  164. return subtle.ConstantTimeCompare(expected, got) == 1
  165. }
  166. func abs(d time.Duration) time.Duration {
  167. if d < 0 {
  168. return -d
  169. }
  170. return d
  171. }
  172. func writeErr(w http.ResponseWriter, code int, reason, detail string) {
  173. w.Header().Set("Content-Type", "application/json")
  174. w.WriteHeader(code)
  175. body := map[string]string{"error": reason}
  176. if detail != "" {
  177. body["detail"] = detail
  178. }
  179. _ = json.NewEncoder(w).Encode(body)
  180. }
  181. // loadSourcesFromEnv parses BA_INGESTD_SOURCES as a comma-separated
  182. // list of company_id:source_id:secret triples. M0 dev-mode only;
  183. // M2 swaps this for a Postgres lookup.
  184. func loadSourcesFromEnv(logger *slog.Logger) map[string]SourceConfig {
  185. out := map[string]SourceConfig{}
  186. raw := os.Getenv("BA_INGESTD_SOURCES")
  187. if raw == "" {
  188. logger.Warn("no BA_INGESTD_SOURCES configured; ingestd will reject all sources")
  189. return out
  190. }
  191. for _, triple := range strings.Split(raw, ",") {
  192. parts := strings.SplitN(triple, ":", 3)
  193. if len(parts) != 3 {
  194. logger.Warn("bad BA_INGESTD_SOURCES entry, expected company:source:secret", "entry", triple)
  195. continue
  196. }
  197. key := parts[0] + ":" + parts[1]
  198. out[key] = SourceConfig{
  199. CompanyID: parts[0],
  200. SourceID: parts[1],
  201. HMACSecret: []byte(parts[2]),
  202. RateLimitPerSec: config.GetInt("BA_INGESTD_RATE_LIMIT_PER_SOURCE", 100),
  203. }
  204. }
  205. logger.Info("loaded sources from env", "count", len(out))
  206. return out
  207. }