|
@@ -15,6 +15,7 @@ package main
|
|
|
import (
|
|
import (
|
|
|
"context"
|
|
"context"
|
|
|
"encoding/json"
|
|
"encoding/json"
|
|
|
|
|
+ "errors"
|
|
|
"fmt"
|
|
"fmt"
|
|
|
"log/slog"
|
|
"log/slog"
|
|
|
"strconv"
|
|
"strconv"
|
|
@@ -22,8 +23,10 @@ import (
|
|
|
|
|
|
|
|
"git3.techno-world.net/lrosales/broad-announce/internal/alert"
|
|
"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/broker"
|
|
|
|
|
+ "git3.techno-world.net/lrosales/broad-announce/internal/circuitbreaker"
|
|
|
"git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
|
|
"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/observability"
|
|
|
|
|
+ "git3.techno-world.net/lrosales/broad-announce/internal/quarantine"
|
|
|
"git3.techno-world.net/lrosales/broad-announce/internal/ratelimit"
|
|
"git3.techno-world.net/lrosales/broad-announce/internal/ratelimit"
|
|
|
"git3.techno-world.net/lrosales/broad-announce/internal/tailhub"
|
|
"git3.techno-world.net/lrosales/broad-announce/internal/tailhub"
|
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go"
|
|
@@ -97,6 +100,13 @@ type processDeps struct {
|
|
|
// (HTTP, MQTT, WS) share the same in-process state and
|
|
// (HTTP, MQTT, WS) share the same in-process state and
|
|
|
// the same max-observed gauge.
|
|
// the same max-observed gauge.
|
|
|
MaxSeen *observability.MaxSeen
|
|
MaxSeen *observability.MaxSeen
|
|
|
|
|
+ // CircuitBreaker is the M9 layer-6 per-component circuit
|
|
|
|
|
+ // breaker wrapping the NATS publish call. Nil is fine (falls
|
|
|
|
|
+ // back to direct publish without circuit protection).
|
|
|
|
|
+ CircuitBreaker *circuitbreaker.Breaker
|
|
|
|
|
+ // Quarantine is the M9 layer-7 per-source error-rate limiter.
|
|
|
|
|
+ // Nil is fine (no quarantine enforcement).
|
|
|
|
|
+ Quarantine *quarantine.Manager
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ProcessAlert runs the full SPEC §22 protection chain on one
|
|
// ProcessAlert runs the full SPEC §22 protection chain on one
|
|
@@ -108,13 +118,15 @@ type processDeps struct {
|
|
|
// 1. payload-size cap (caller does this — http.go via
|
|
// 1. payload-size cap (caller does this — http.go via
|
|
|
// MaxBytesReader; mqtt.go via the
|
|
// MaxBytesReader; mqtt.go via the
|
|
|
// broker-side max-inflight setting)
|
|
// broker-side max-inflight setting)
|
|
|
|
|
+// 2. quarantine check (M9 layer 7) — per-source ban if error
|
|
|
|
|
+// rate exceeds threshold
|
|
|
// 3. per-source rate limit
|
|
// 3. per-source rate limit
|
|
|
// 4. per-company rate limit
|
|
// 4. per-company rate limit
|
|
|
// 5. schema validate
|
|
// 5. schema validate
|
|
|
// 5b. parse JSON
|
|
// 5b. parse JSON
|
|
|
// (auth) HMAC verify — see verifyHMAC
|
|
// (auth) HMAC verify — see verifyHMAC
|
|
|
// 5c. dedupe
|
|
// 5c. dedupe
|
|
|
-// publish to NATS
|
|
|
|
|
|
|
+// publish to NATS (M9 layer 6 circuit breaker wraps this)
|
|
|
func (d *processDeps) ProcessAlert(ctx context.Context, body []byte, sigHeader string) Result {
|
|
func (d *processDeps) ProcessAlert(ctx context.Context, body []byte, sigHeader string) Result {
|
|
|
now := d.now()
|
|
now := d.now()
|
|
|
|
|
|
|
@@ -137,6 +149,21 @@ func (d *processDeps) ProcessAlert(ctx context.Context, body []byte, sigHeader s
|
|
|
fmt.Sprintf("no such source %s/%s", a.CompanyID, a.SourceID))
|
|
fmt.Sprintf("no such source %s/%s", a.CompanyID, a.SourceID))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // M9 layer 7: quarantine check. Check before we spend any CPU
|
|
|
|
|
+ // on a known-bad source.
|
|
|
|
|
+ if d.Quarantine != nil {
|
|
|
|
|
+ if banned, remaining, err := d.Quarantine.IsBanned(ctx, a.SourceID); err == nil && banned {
|
|
|
|
|
+ d.Metrics.AlertsReceived.WithLabelValues("quarantined").Inc()
|
|
|
|
|
+ d.Logger.Warn("source quarantined",
|
|
|
|
|
+ "source_id", a.SourceID,
|
|
|
|
|
+ "company_id", a.CompanyID,
|
|
|
|
|
+ "remaining", remaining,
|
|
|
|
|
+ )
|
|
|
|
|
+ return Reject("quarantined", 429,
|
|
|
|
|
+ fmt.Sprintf("source quarantined for %v; retry after", remaining.Round(time.Second)))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// Auth. Stripe-style: X-BA-Signature: t=<unix>,v1=<hex>.
|
|
// Auth. Stripe-style: X-BA-Signature: t=<unix>,v1=<hex>.
|
|
|
// For HTTP it's a header; for MQTT it's a top-level field
|
|
// For HTTP it's a header; for MQTT it's a top-level field
|
|
|
// on the envelope — both call sites pass the same string.
|
|
// on the envelope — both call sites pass the same string.
|
|
@@ -145,6 +172,25 @@ func (d *processDeps) ProcessAlert(ctx context.Context, body []byte, sigHeader s
|
|
|
return Reject("bad_signature", 401, "")
|
|
return Reject("bad_signature", 401, "")
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // M9: quarantine hit tracking. Any rejection after the HMAC
|
|
|
|
|
+ // check (where we know the source is real) indicates a
|
|
|
|
|
+ // problematic source. We use a closure so the defer pattern
|
|
|
|
|
+ // catches every return path without boilerplate at each one.
|
|
|
|
|
+ var hitRecorded bool
|
|
|
|
|
+ defer func() {
|
|
|
|
|
+ if !hitRecorded && d.Quarantine != nil {
|
|
|
|
|
+ _ = d.Quarantine.RecordHit(context.Background(), a.SourceID)
|
|
|
|
|
+ }
|
|
|
|
|
+ }()
|
|
|
|
|
+ // recordHit records a quarantine error hit for a.SourceID.
|
|
|
|
|
+ // Call it before any rejection return.
|
|
|
|
|
+ recordHit := func() {
|
|
|
|
|
+ if d.Quarantine != nil && !hitRecorded {
|
|
|
|
|
+ hitRecorded = true
|
|
|
|
|
+ _ = d.Quarantine.RecordHit(context.Background(), a.SourceID)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// M6: Dedupe BEFORE rate limit. A duplicate (isNew=false)
|
|
// M6: Dedupe BEFORE rate limit. A duplicate (isNew=false)
|
|
|
// is a Redis INCR + JSON marshal + NATS publish — it does
|
|
// is a Redis INCR + JSON marshal + NATS publish — it does
|
|
|
// not warrant burning a rate-limit token. The rate limit
|
|
// not warrant burning a rate-limit token. The rate limit
|
|
@@ -182,6 +228,7 @@ func (d *processDeps) ProcessAlert(ctx context.Context, body []byte, sigHeader s
|
|
|
d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
|
|
d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
|
|
|
d.Metrics.RateLimitHits.WithLabelValues("source").Inc()
|
|
d.Metrics.RateLimitHits.WithLabelValues("source").Inc()
|
|
|
_ = ttl
|
|
_ = ttl
|
|
|
|
|
+ recordHit()
|
|
|
return Reject("rate_limited_source", 429, strconv.Itoa(int(ttl.Seconds())))
|
|
return Reject("rate_limited_source", 429, strconv.Itoa(int(ttl.Seconds())))
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -192,6 +239,7 @@ func (d *processDeps) ProcessAlert(ctx context.Context, body []byte, sigHeader s
|
|
|
d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
|
|
d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
|
|
|
d.Metrics.RateLimitHits.WithLabelValues("company").Inc()
|
|
d.Metrics.RateLimitHits.WithLabelValues("company").Inc()
|
|
|
_ = ttl
|
|
_ = ttl
|
|
|
|
|
+ recordHit()
|
|
|
return Reject("rate_limited_company", 429, strconv.Itoa(int(ttl.Seconds())))
|
|
return Reject("rate_limited_company", 429, strconv.Itoa(int(ttl.Seconds())))
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -201,7 +249,8 @@ func (d *processDeps) ProcessAlert(ctx context.Context, body []byte, sigHeader s
|
|
|
a.ReceivedAt = now.UTC()
|
|
a.ReceivedAt = now.UTC()
|
|
|
a.DedupeCount = count
|
|
a.DedupeCount = count
|
|
|
|
|
|
|
|
- // Publish to NATS.
|
|
|
|
|
|
|
+ // Publish to NATS. M9 layer 6: circuit breaker wraps the
|
|
|
|
|
+ // publish call so a sick NATS server doesn't take down ingestd.
|
|
|
subject := broker.AlertsSubject(a.CompanyID)
|
|
subject := broker.AlertsSubject(a.CompanyID)
|
|
|
payload, err := json.Marshal(a)
|
|
payload, err := json.Marshal(a)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
@@ -209,10 +258,33 @@ func (d *processDeps) ProcessAlert(ctx context.Context, body []byte, sigHeader s
|
|
|
return Reject("marshal_failed", 500, err.Error())
|
|
return Reject("marshal_failed", 500, err.Error())
|
|
|
}
|
|
}
|
|
|
start := time.Now()
|
|
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()
|
|
|
|
|
- return Reject("broker_unavailable", 503, err.Error())
|
|
|
|
|
|
|
+ var publishErr error
|
|
|
|
|
+ if d.CircuitBreaker != nil {
|
|
|
|
|
+ // Wrap the synchronous publish in the circuit breaker.
|
|
|
|
|
+ // We use Publish (sync) so Do() gets immediate feedback.
|
|
|
|
|
+ publishErr = d.CircuitBreaker.Do(ctx, func() error {
|
|
|
|
|
+ return d.JetStream.Publish(subject, payload)
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ publishErr = d.JetStream.Publish(subject, payload)
|
|
|
|
|
+ }
|
|
|
|
|
+ if publishErr != nil {
|
|
|
|
|
+ if errors.Is(publishErr, circuitbreaker.ErrCircuitOpen) {
|
|
|
|
|
+ d.Metrics.AlertsReceived.WithLabelValues("circuit_open").Inc()
|
|
|
|
|
+ d.Metrics.CBState.WithLabelValues("nats").Set(circuitbreaker.StateOpen)
|
|
|
|
|
+ d.Logger.Warn("circuit breaker open",
|
|
|
|
|
+ "subject", subject,
|
|
|
|
|
+ "alert_id", a.ID,
|
|
|
|
|
+ "company_id", a.CompanyID,
|
|
|
|
|
+ )
|
|
|
|
|
+ recordHit()
|
|
|
|
|
+ return Reject("circuit_open", 503, "broker circuit breaker open")
|
|
|
|
|
+ }
|
|
|
|
|
+ // Real publish error (network, auth, etc.).
|
|
|
|
|
+ d.Metrics.AlertsReceived.WithLabelValues("broker_unavailable").Inc()
|
|
|
|
|
+ d.Logger.Error("nats publish", "err", publishErr, "subject", subject)
|
|
|
|
|
+ recordHit()
|
|
|
|
|
+ return Reject("broker_unavailable", 503, publishErr.Error())
|
|
|
}
|
|
}
|
|
|
d.Metrics.PublishLatency.Observe(time.Since(start).Seconds())
|
|
d.Metrics.PublishLatency.Observe(time.Since(start).Seconds())
|
|
|
d.Metrics.PayloadBytes.Observe(float64(len(payload)))
|
|
d.Metrics.PayloadBytes.Observe(float64(len(payload)))
|
|
@@ -238,6 +310,9 @@ func (d *processDeps) ProcessAlert(ctx context.Context, body []byte, sigHeader s
|
|
|
ev := tailhub.FromAlert(&a, d.Transport)
|
|
ev := tailhub.FromAlert(&a, d.Transport)
|
|
|
d.Tail.Publish(ev)
|
|
d.Tail.Publish(ev)
|
|
|
}
|
|
}
|
|
|
|
|
+ // Accepted: mark that we did not get a rejection so the
|
|
|
|
|
+ // defer does not record a spurious quarantine hit.
|
|
|
|
|
+ hitRecorded = true
|
|
|
return Accept(a.ID, count, isNew)
|
|
return Accept(a.ID, count, isNew)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -252,6 +327,9 @@ func (d *processDeps) now() time.Time {
|
|
|
// MQTT paths share it; tests can swap in a fake.
|
|
// MQTT paths share it; tests can swap in a fake.
|
|
|
type natsPublisher interface {
|
|
type natsPublisher interface {
|
|
|
PublishAsync(subj string, data []byte) error
|
|
PublishAsync(subj string, data []byte) error
|
|
|
|
|
+ // Publish is synchronous. The circuit breaker uses this
|
|
|
|
|
+ // to get immediate success/failure feedback.
|
|
|
|
|
+ Publish(subj string, data []byte) error
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// jsPublisher adapts a nats.JetStreamContext to the natsPublisher interface.
|
|
// jsPublisher adapts a nats.JetStreamContext to the natsPublisher interface.
|
|
@@ -259,6 +337,13 @@ type jsPublisher struct {
|
|
|
js nats.JetStreamContext
|
|
js nats.JetStreamContext
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// Publish is synchronous (blocks until server ack or timeout). Used
|
|
|
|
|
+// by the circuit breaker which needs immediate success/failure feedback.
|
|
|
|
|
+func (j *jsPublisher) Publish(subj string, data []byte) error {
|
|
|
|
|
+ _, err := j.js.Publish(subj, data)
|
|
|
|
|
+ return err
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
func (j *jsPublisher) PublishAsync(subj string, data []byte) error {
|
|
func (j *jsPublisher) PublishAsync(subj string, data []byte) error {
|
|
|
_, err := j.js.PublishAsync(subj, data)
|
|
_, err := j.js.PublishAsync(subj, data)
|
|
|
return err
|
|
return err
|