|
|
@@ -23,7 +23,6 @@ import (
|
|
|
"encoding/hex"
|
|
|
"encoding/json"
|
|
|
"errors"
|
|
|
- "fmt"
|
|
|
"io"
|
|
|
"log/slog"
|
|
|
"net/http"
|
|
|
@@ -32,28 +31,16 @@ import (
|
|
|
"strings"
|
|
|
"time"
|
|
|
|
|
|
- "git3.techno-world.net/lrosales/broad-announce/internal/alert"
|
|
|
- "git3.techno-world.net/lrosales/broad-announce/internal/broker"
|
|
|
"git3.techno-world.net/lrosales/broad-announce/internal/config"
|
|
|
- "git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
|
|
|
- "git3.techno-world.net/lrosales/broad-announce/internal/observability"
|
|
|
- "git3.techno-world.net/lrosales/broad-announce/internal/ratelimit"
|
|
|
- "github.com/nats-io/nats.go"
|
|
|
)
|
|
|
|
|
|
-// httpDeps is what the handler needs. Injected so tests can swap.
|
|
|
+// httpDeps is the HTTP-handler-scoped wrapper. It embeds
|
|
|
+// processDeps (the shared pipeline used by both HTTP and MQTT)
|
|
|
+// and adds the HTTP-only fields (MaxBytes, raw request/response
|
|
|
+// types). The body of the handler is a single ProcessAlert call.
|
|
|
type httpDeps struct {
|
|
|
- Logger *slog.Logger
|
|
|
- Metrics *observability.IngestdMetrics
|
|
|
- Limiter *ratelimit.Limiter
|
|
|
- Deduper *dedupe.Deduper
|
|
|
- JetStream natsPublisher
|
|
|
- MaxBytes int
|
|
|
- // For M0 we skip the DB lookup and read sources from a small
|
|
|
- // static map. M2 replaces this with a real store.
|
|
|
- Sources map[string]SourceConfig
|
|
|
- // Now is overridable in tests.
|
|
|
- Now func() time.Time
|
|
|
+ processDeps
|
|
|
+ MaxBytes int
|
|
|
}
|
|
|
|
|
|
// SourceConfig is what we need to know about a source to authenticate
|
|
|
@@ -66,11 +53,6 @@ type SourceConfig struct {
|
|
|
AllowedTargets []string // M2
|
|
|
}
|
|
|
|
|
|
-// natsPublisher is the minimal NATS interface the handler uses.
|
|
|
-type natsPublisher interface {
|
|
|
- PublishAsync(subj string, data []byte) error
|
|
|
-}
|
|
|
-
|
|
|
// AcceptResponse is the JSON body returned on 202.
|
|
|
type AcceptResponse struct {
|
|
|
AlertID string `json:"alert_id"`
|
|
|
@@ -83,12 +65,11 @@ func RegisterRoutes(mux *http.ServeMux, d *httpDeps) {
|
|
|
mux.HandleFunc("POST /v1/ingest", d.handleIngest)
|
|
|
}
|
|
|
|
|
|
-// handleIngest is the M0 HTTP POST endpoint. Auth, validate, dedupe,
|
|
|
-// publish.
|
|
|
+// handleIngest is the M0 HTTP POST endpoint. The body of the
|
|
|
+// pipeline is the shared processDeps.ProcessAlert; this handler
|
|
|
+// only adds the HTTP-specific bits (MaxBytesReader, header
|
|
|
+// signature, response shape).
|
|
|
func (d *httpDeps) handleIngest(w http.ResponseWriter, r *http.Request) {
|
|
|
- ctx := r.Context()
|
|
|
- now := d.now()
|
|
|
-
|
|
|
// 1. Payload-size cap. We use MaxBytesReader so a streaming
|
|
|
// client can't lie about Content-Length and try to OOM us.
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, int64(d.MaxBytes))
|
|
|
@@ -106,120 +87,30 @@ func (d *httpDeps) handleIngest(w http.ResponseWriter, r *http.Request) {
|
|
|
}
|
|
|
_ = r.Body.Close()
|
|
|
|
|
|
- // 5. Parse + validate. We treat any parse failure as invalid.
|
|
|
- var a alert.Alert
|
|
|
- if err := json.Unmarshal(body, &a); err != nil {
|
|
|
- d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
|
|
|
- writeErr(w, http.StatusBadRequest, "invalid_json", err.Error())
|
|
|
- return
|
|
|
- }
|
|
|
- if err := a.Validate(); err != nil {
|
|
|
- d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
|
|
|
- writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // Look up source. M0: in-memory map. M2: DB.
|
|
|
- src, ok := d.Sources[a.CompanyID+":"+a.SourceID]
|
|
|
- if !ok {
|
|
|
- d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
|
|
|
- writeErr(w, http.StatusUnauthorized, "unknown_source",
|
|
|
- fmt.Sprintf("no such source %s/%s", a.CompanyID, a.SourceID))
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // Auth. Stripe-style: X-BA-Signature: t=<unix>,v1=<hex>
|
|
|
- if !verifyHMAC(r.Header.Get("X-BA-Signature"), src.HMACSecret, body, now) {
|
|
|
- d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
|
|
|
- writeErr(w, http.StatusUnauthorized, "bad_signature", "")
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // 3. Per-source rate limit.
|
|
|
- if ok, ttl, err := d.Limiter.Allow(ctx, "source:"+a.CompanyID+":"+a.SourceID, src.RateLimitPerSec); err != nil {
|
|
|
- // Fail open on Redis errors — we don't want a Redis blip
|
|
|
- // to take down ingestion. Log loud, count it.
|
|
|
- d.Logger.Warn("ratelimit redis error (failing open)", "err", err, "scope", "source")
|
|
|
- } else if !ok {
|
|
|
- d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
|
|
|
- d.Metrics.RateLimitHits.WithLabelValues("source").Inc()
|
|
|
- w.Header().Set("Retry-After", strconv.Itoa(int(ttl.Seconds())))
|
|
|
- writeErr(w, http.StatusTooManyRequests, "rate_limited_source", "")
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // 4. Per-company rate limit (cap from config; M2 will pull from DB).
|
|
|
- // For M0 we just use a constant default; replace with config load
|
|
|
- // once that lands.
|
|
|
- if ok, ttl, _ := d.Limiter.Allow(ctx, "company:"+a.CompanyID, 10_000); !ok {
|
|
|
- d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
|
|
|
- d.Metrics.RateLimitHits.WithLabelValues("company").Inc()
|
|
|
- w.Header().Set("Retry-After", strconv.Itoa(int(ttl.Seconds())))
|
|
|
- writeErr(w, http.StatusTooManyRequests, "rate_limited_company", "")
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // 5b. Dedupe.
|
|
|
- isNew, count, err := d.Deduper.Check(ctx, a.SourceID, a.DedupeKey)
|
|
|
- if err != nil {
|
|
|
- // Fail open on dedupe errors too.
|
|
|
- d.Logger.Warn("dedupe redis error (failing open)", "err", err)
|
|
|
- isNew, count = true, 1
|
|
|
- }
|
|
|
-
|
|
|
- // Stamp server-side fields.
|
|
|
- a.ID = alert.NewID()
|
|
|
- a.ReceivedAt = now.UTC()
|
|
|
- a.DedupeCount = count
|
|
|
-
|
|
|
- // Publish to NATS.
|
|
|
- subject := broker.AlertsSubject(a.CompanyID)
|
|
|
- payload, err := json.Marshal(a)
|
|
|
- if err != nil {
|
|
|
- d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
|
|
|
- writeErr(w, http.StatusInternalServerError, "marshal_failed", err.Error())
|
|
|
- return
|
|
|
- }
|
|
|
- start := time.Now()
|
|
|
- if err := d.JetStream.PublishAsync(subject, payload); err != nil {
|
|
|
- // Circuit breaker (M9) wraps this. For M0 we just fail loud.
|
|
|
- d.Metrics.AlertsReceived.WithLabelValues("circuit_open").Inc()
|
|
|
- writeErr(w, http.StatusServiceUnavailable, "broker_unavailable", err.Error())
|
|
|
+ res := d.ProcessAlert(r.Context(), body, r.Header.Get("X-BA-Signature"))
|
|
|
+ if !res.Accepted {
|
|
|
+ // Rate-limit rejections get a Retry-After header.
|
|
|
+ if res.RejectReason == "rate_limited_source" || res.RejectReason == "rate_limited_company" {
|
|
|
+ if n, err := strconv.Atoi(res.Detail); err == nil {
|
|
|
+ w.Header().Set("Retry-After", strconv.Itoa(n))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ writeErr(w, res.HTTPStatus, res.RejectReason, res.Detail)
|
|
|
return
|
|
|
}
|
|
|
- d.Metrics.PublishLatency.Observe(time.Since(start).Seconds())
|
|
|
- d.Metrics.PayloadBytes.Observe(float64(len(payload)))
|
|
|
-
|
|
|
- if isNew {
|
|
|
- d.Metrics.AlertsReceived.WithLabelValues("accepted").Inc()
|
|
|
- } else {
|
|
|
- d.Metrics.AlertsReceived.WithLabelValues("deduped").Inc()
|
|
|
- }
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
w.WriteHeader(http.StatusAccepted)
|
|
|
_ = json.NewEncoder(w).Encode(AcceptResponse{
|
|
|
- AlertID: a.ID,
|
|
|
- DedupeCount: count,
|
|
|
- ReceivedAt: a.ReceivedAt.Format(time.RFC3339Nano),
|
|
|
+ AlertID: res.AlertID,
|
|
|
+ DedupeCount: res.DedupeCount,
|
|
|
+ ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
|
})
|
|
|
-
|
|
|
- d.Logger.Info("alert accepted",
|
|
|
- "alert_id", a.ID,
|
|
|
- "company_id", a.CompanyID,
|
|
|
- "source_id", a.SourceID,
|
|
|
- "severity", string(a.Severity),
|
|
|
- "dedupe_count", count,
|
|
|
- )
|
|
|
}
|
|
|
|
|
|
-// now returns the testable clock.
|
|
|
-func (d *httpDeps) now() time.Time {
|
|
|
- if d.Now != nil {
|
|
|
- return d.Now()
|
|
|
- }
|
|
|
- return time.Now()
|
|
|
-}
|
|
|
+// now is a thin alias for processDeps.now (kept here so the
|
|
|
+// existing tests that call d.now() on httpDeps still work).
|
|
|
+func (d *httpDeps) now() time.Time { return d.processDeps.now() }
|
|
|
|
|
|
// verifyHMAC parses `X-BA-Signature: t=<unix>,v1=<hex>` and checks
|
|
|
// HMAC-SHA256(secret, "<unix>.<body>") == hex. Replay window: 5 min.
|
|
|
@@ -280,21 +171,6 @@ func writeErr(w http.ResponseWriter, code int, reason, detail string) {
|
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
|
}
|
|
|
|
|
|
-// jsPublisher adapts a nats.JetStreamContext to the natsPublisher interface.
|
|
|
-type jsPublisher struct {
|
|
|
- js nats.JetStreamContext
|
|
|
-}
|
|
|
-
|
|
|
-func (j *jsPublisher) PublishAsync(subj string, data []byte) error {
|
|
|
- _, err := j.js.PublishAsync(subj, data)
|
|
|
- return err
|
|
|
-}
|
|
|
-
|
|
|
-// newNatsPublisher is the constructor used by main.
|
|
|
-func newNatsPublisher(js nats.JetStreamContext) natsPublisher {
|
|
|
- return &jsPublisher{js: js}
|
|
|
-}
|
|
|
-
|
|
|
// loadSourcesFromEnv parses BA_INGESTD_SOURCES as a comma-separated
|
|
|
// list of company_id:source_id:secret triples. M0 dev-mode only;
|
|
|
// M2 swaps this for a Postgres lookup.
|