main.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. // Command ingestd receives alerts via HTTP POST / WebSocket / MQTT / gRPC,
  2. // validates, rate-limits, dedupes, and publishes to NATS JetStream.
  3. //
  4. // M0: HTTP POST endpoint only. Other transports land in M5 / M4 / M11.
  5. package main
  6. import (
  7. "context"
  8. "log/slog"
  9. "net"
  10. "os"
  11. "os/signal"
  12. "syscall"
  13. "time"
  14. "git3.techno-world.net/lrosales/broad-announce/internal/authd"
  15. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  16. "git3.techno-world.net/lrosales/broad-announce/internal/circuitbreaker"
  17. "git3.techno-world.net/lrosales/broad-announce/internal/concurrency"
  18. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  19. "git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
  20. "git3.techno-world.net/lrosales/broad-announce/internal/grpcserver"
  21. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  22. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  23. "git3.techno-world.net/lrosales/broad-announce/internal/pipeline"
  24. "git3.techno-world.net/lrosales/broad-announce/internal/quarantine"
  25. "git3.techno-world.net/lrosales/broad-announce/internal/ratelimit"
  26. "git3.techno-world.net/lrosales/broad-announce/internal/store"
  27. "git3.techno-world.net/lrosales/broad-announce/internal/tailhub"
  28. )
  29. // grpcDeps implements grpcserver.IngestdDeps so internal/grpcserver doesn't
  30. // import cmd/ingestd (which would create a cycle).
  31. type grpcDeps struct {
  32. pipeline pipeline.Deps
  33. logger *slog.Logger
  34. metrics *observability.IngestdMetrics
  35. }
  36. func (g *grpcDeps) GetPipeline() pipeline.Deps { return g.pipeline }
  37. func (g *grpcDeps) GetLogger() interface{ Info(string, ...any) } { return g.logger }
  38. func (g *grpcDeps) GetMetrics() *observability.IngestdMetrics { return g.metrics }
  39. func main() {
  40. cfg, err := config.LoadIngestd()
  41. if err != nil {
  42. // Logger isn't up yet; stderr is the only thing we have.
  43. os.Stderr.WriteString("config: " + err.Error() + "\n")
  44. os.Exit(1)
  45. }
  46. logger := observability.Init(cfg.Env, cfg.LogLevel, "ingestd")
  47. logger.Info("starting", "env", cfg.Env, "addr", cfg.HTTPAddr)
  48. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  49. defer stop()
  50. // Redis
  51. r, err := store.ConnectRedis(ctx, cfg.RedisURL)
  52. if err != nil {
  53. logger.Error("redis connect", "err", err)
  54. os.Exit(1)
  55. }
  56. defer r.Close()
  57. logger.Info("redis connected")
  58. // NATS JetStream
  59. br, err := broker.Connect(ctx, cfg.NATSURL)
  60. if err != nil {
  61. logger.Error("nats connect", "err", err)
  62. os.Exit(1)
  63. }
  64. defer br.Close()
  65. logger.Info("nats connected", "url", cfg.NATSURL)
  66. // Metrics
  67. reg, m := observability.NewRegistry("ingestd")
  68. limiter := ratelimit.New(r.Client)
  69. // M6: sliding-window dedupe TTL from config. Default 300s
  70. // (5 min), overridable via BA_INGESTD_DEDUPE_TTL_SECONDS.
  71. dedTTL := time.Duration(cfg.DedupeTTLSeconds) * time.Second
  72. if dedTTL <= 0 {
  73. dedTTL = dedupe.DefaultWindow
  74. }
  75. ded := dedupe.New(r.Client, dedTTL)
  76. // M9: circuit breaker (layer 6). Wraps the NATS publish
  77. // call so a sick broker doesn't take down ingestd.
  78. cbCfg := circuitbreaker.Config{
  79. Name: "nats-publish",
  80. FailureThreshold: cfg.CircuitFailureThreshold,
  81. FailureWindow: time.Duration(cfg.CircuitFailureWindowSecs) * time.Second,
  82. OpenDuration: time.Duration(cfg.CircuitOpenDurationSecs) * time.Second,
  83. MaxHalfOpen: cfg.CircuitMaxHalfOpen,
  84. }
  85. cb := circuitbreaker.New(cbCfg)
  86. // Also report CB state changes to Prometheus.
  87. cb.Measure = func(state int, err error) {
  88. m.CBState.WithLabelValues("nats").Set(float64(state))
  89. }
  90. // Initialize gauge to CLOSED (0) so Prometheus sees the metric before first transition.
  91. m.CBState.WithLabelValues("nats").Set(0)
  92. logger.Info("circuit breaker configured",
  93. "failure_threshold", cfg.CircuitFailureThreshold,
  94. "failure_window_sec", cfg.CircuitFailureWindowSecs,
  95. "open_duration_sec", cfg.CircuitOpenDurationSecs,
  96. "max_half_open", cfg.CircuitMaxHalfOpen,
  97. )
  98. // M9: quarantine manager (layer 7). Per-source error-rate
  99. // limiter backed by Redis so the ban is shared across
  100. // multiple ingestd instances.
  101. quarantineCfg := quarantine.Config{
  102. HitsThreshold: cfg.QuarantineHitsThreshold,
  103. HitsWindow: time.Duration(cfg.QuarantineWindowSeconds) * time.Second,
  104. BanDuration: time.Duration(cfg.QuarantineDurationSecond) * time.Second,
  105. }
  106. q := quarantine.New(r.Client, quarantineCfg)
  107. logger.Info("quarantine configured",
  108. "hits_threshold", cfg.QuarantineHitsThreshold,
  109. "hits_window_sec", cfg.QuarantineWindowSeconds,
  110. "ban_duration_sec", cfg.QuarantineDurationSecond,
  111. )
  112. // M0 source registry: loaded from env. M2 replaces with DB.
  113. sources := loadSourcesFromEnv(logger)
  114. js, err := br.NC().JetStream()
  115. if err != nil {
  116. logger.Error("nats jetstream context", "err", err)
  117. os.Exit(1)
  118. }
  119. // M5: per-IP concurrency cap (SPEC §22 layer 2). The same
  120. // gate is used by the HTTP server (via the WS path; HTTP's
  121. // per-IP cap is M10's "M5-bump" deferred — see SPEC §22
  122. // milestone rollout) and the WebSocket ingest path.
  123. perIP := concurrency.NewPerIP(cfg.MaxConcurrentPerIP)
  124. defer perIP.Close()
  125. // M5: in-process tail hub. nil-safe; both the HTTP and
  126. // MQTT paths call Tail.Publish if non-nil.
  127. hub := tailhub.NewHub()
  128. // M6: per-source monotonic max tracker for dedupe_count.
  129. // Shared by all transports so the
  130. // ba_ingestd_dedupe_count_max_observed gauge reflects the
  131. // global peak across HTTP, MQTT, and WS.
  132. maxSeen := observability.NewMaxSeen()
  133. // grpcDeps adapts ingestd's concrete types to the grpcserver.IngestdDeps
  134. // interface so internal/grpcserver doesn't need to import cmd/ingestd.
  135. grpcDep := &grpcDeps{
  136. pipeline: pipeline.Deps{
  137. Logger: logger.With("component", "grpc"),
  138. Metrics: m,
  139. Limiter: limiter,
  140. Deduper: ded,
  141. Sources: sources,
  142. JetStream: pipeline.NewNatsPublisher(js),
  143. CompanyRatePerSec: cfg.RateLimitPerCompany,
  144. Tail: hub,
  145. Transport: "grpc",
  146. MaxSeen: maxSeen,
  147. CircuitBreaker: cb,
  148. Quarantine: q,
  149. },
  150. logger: logger,
  151. metrics: m,
  152. }
  153. deps := &httpDeps{
  154. processDeps: processDeps{pipeline.Deps{
  155. Logger: logger.With("component", "http"),
  156. Metrics: m,
  157. Limiter: limiter,
  158. Deduper: ded,
  159. Sources: sources,
  160. JetStream: pipeline.NewNatsPublisher(js),
  161. CompanyRatePerSec: cfg.RateLimitPerCompany,
  162. Tail: hub,
  163. Transport: "http",
  164. MaxSeen: maxSeen,
  165. CircuitBreaker: cb,
  166. Quarantine: q,
  167. }},
  168. MaxBytes: cfg.MaxPayloadBytes,
  169. }
  170. // M5: WS ingest + tail handler. The WS path uses a copy of
  171. // deps.processDeps with Transport="ws" so the structured
  172. // log lines and the tail event label are correct.
  173. wsDeps := &wsIngestDeps{
  174. processDeps: deps.processDeps,
  175. PerIP: perIP,
  176. MaxFrameBytes: int64(cfg.MaxPayloadBytes),
  177. ReadDeadline: 30 * time.Second,
  178. WriteDeadline: 10 * time.Second,
  179. }
  180. // Override the transport on the wsDeps copy. We have to
  181. // use a fresh processDeps because the embedded struct is a
  182. // value, not a pointer, in the struct literal above.
  183. wsDeps.processDeps.Logger = logger.With("component", "ws")
  184. wsDeps.processDeps.Transport = "ws"
  185. tailDeps := &wsTailDeps{
  186. Token: os.Getenv("BA_INGESTD_TAIL_TOKEN"),
  187. Hub: hub,
  188. Metrics: m,
  189. }
  190. // HTTP server
  191. srv := httpserver.New(httpserver.Config{
  192. Addr: cfg.HTTPAddr,
  193. ServiceName: "ingestd",
  194. ShutdownGrace: cfg.ShutdownGrace,
  195. }, logger, observability.MetricsHandler(reg))
  196. RegisterRoutes(srv.Mux(), deps)
  197. RegisterWSRoutes(srv.Mux(), wsDeps, tailDeps)
  198. // M13a W2: JWT-protected admin ingest. Only wired if
  199. // BA_INGESTD_AUTHD_JWT_SECRET is set (the same secret authd
  200. // uses). When the env is empty, RegisterAdminRoutes is a
  201. // no-op so non-M13 deployments are unaffected.
  202. if authdSecret := os.Getenv("BA_INGESTD_AUTHD_JWT_SECRET"); authdSecret != "" {
  203. ad, err := authd.New(nil, authd.Config{
  204. JWTSecret: []byte(authdSecret),
  205. Issuer: os.Getenv("BA_AUTHD_ISSUER"),
  206. AccessTokenTTL: 15 * time.Minute, // unused here, but required
  207. })
  208. if err != nil {
  209. logger.Error("failed to init authd (admin ingest disabled)", "err", err)
  210. } else {
  211. deps.Authd = ad
  212. RegisterAdminRoutes(srv.Mux(), deps)
  213. logger.Info("admin ingest route enabled (JWT-gated)")
  214. }
  215. }
  216. // MQTT subscriber (M4). Disabled if BA_INGESTD_MQTT_BROKER is
  217. // empty. The subscriber shares the processDeps with the HTTP
  218. // handler so the dedupe window, rate limits, and metrics are
  219. // per-source exactly once across both transports.
  220. mqttCfg := loadMQTTConfig(logger)
  221. mqttDeps := deps.processDeps
  222. mqttDeps.Logger = logger.With("component", "mqtt")
  223. mqttDeps.Transport = "mqtt"
  224. mqttErrCh := make(chan error, 1)
  225. go func() {
  226. mqttErrCh <- startMQTT(ctx, mqttCfg, &mqttDeps, logger, m)
  227. }()
  228. grpcSrv, err := grpcserver.NewForIngestd(grpcserver.Config{
  229. Addr: cfg.GRPCAddr,
  230. MaxInflight: cfg.GRPCMaxInflight,
  231. }, grpcDep)
  232. if err != nil {
  233. logger.Error("grpcserver.NewForIngestd", "err", err)
  234. os.Exit(1)
  235. }
  236. grpcLn, err := net.Listen("tcp", cfg.GRPCAddr)
  237. if err != nil {
  238. logger.Error("grpc listen", "addr", cfg.GRPCAddr, "err", err)
  239. os.Exit(1)
  240. }
  241. go func() { grpcSrv.Serve(grpcLn) }()
  242. logger.Info("grpc server started", "addr", cfg.GRPCAddr)
  243. // Run + graceful shutdown
  244. errCh := make(chan error, 1)
  245. go func() { errCh <- srv.Start() }()
  246. select {
  247. case <-ctx.Done():
  248. logger.Info("shutdown signal received")
  249. case err := <-errCh:
  250. if err != nil {
  251. logger.Error("http server", "err", err)
  252. os.Exit(1)
  253. }
  254. case err := <-mqttErrCh:
  255. if err != nil {
  256. logger.Error("mqtt subscriber", "err", err)
  257. os.Exit(1)
  258. }
  259. }
  260. if err := srv.Shutdown(ctx); err != nil {
  261. logger.Warn("graceful shutdown", "err", err)
  262. }
  263. logger.Info("bye")
  264. }