wstail.go 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. // WebSocket live-tail endpoint for ingestd (M5). The
  2. // server-sent companion to /v1/ingest/ws: the operator opens
  3. // this socket and receives a stream of accepted alert events.
  4. //
  5. // Endpoint:
  6. //
  7. // GET /v1/tail/ws[?company_id=acme-001&token=***] HTTP Upgrade → WebSocket
  8. //
  9. // Auth: a static token. The token is supplied as
  10. // - Authorization: Bearer <token>
  11. // - X-BA-Tail-Token: <token>
  12. // - ?token=<token> query param (for wscat)
  13. //
  14. // The static token is set via BA_INGESTD_TAIL_TOKEN. M11 will
  15. // swap this for a JWT signed by admind.
  16. //
  17. // Protocol (server → client only):
  18. //
  19. // {"alert_id":"...","company_id":"...","source_id":"...",
  20. // "severity":"...","title":"...","received_at":"...",
  21. // "transport":"http|mqtt|ws","dedupe_count":N}
  22. //
  23. // One frame per accepted alert. The full alert payload is NOT
  24. // included (could be 256 KB); the operator gets the metadata
  25. // needed to filter, then drills in via the per-alert GET
  26. // endpoint (M9) for the body.
  27. //
  28. // Backpressure: a slow client has events dropped (counter
  29. // ticks); the producer never blocks. The client may keep the
  30. // connection open and catch up; we don't disconnect on drops.
  31. package main
  32. import (
  33. "log/slog"
  34. "net/http"
  35. "strings"
  36. "time"
  37. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  38. "git3.techno-world.net/lrosales/broad-announce/internal/tailhub"
  39. )
  40. // wsTailDeps is the WS tail handler's dependency set.
  41. type wsTailDeps struct {
  42. // Token is the static auth token (env: BA_INGESTD_TAIL_TOKEN).
  43. // Empty disables the endpoint (the route is still wired but
  44. // every request gets 503). This keeps the safety property
  45. // "if the env var is unset, the endpoint is a no-op".
  46. Token string
  47. // Hub is the in-process tail hub. Subscriptions are
  48. // created on each upgrade and released on close.
  49. Hub *tailhub.Hub
  50. // Metrics is the shared observability bundle. We update
  51. // tail_subscribers (gauge) and tail_dropped_total on
  52. // disconnect / slow consumer.
  53. Metrics *observability.IngestdMetrics
  54. // Logger is the tail's per-handler logger. Set by main.
  55. Logger *slog.Logger
  56. }
  57. // handleTail is the /v1/tail/ws upgrade handler. It runs in
  58. // its own goroutine per connection (the gorilla default).
  59. func (d *wsTailDeps) handleTail(w http.ResponseWriter, r *http.Request) {
  60. if d.Token == "" {
  61. http.Error(w, "tail endpoint disabled (BA_INGESTD_TAIL_TOKEN unset)", http.StatusServiceUnavailable)
  62. return
  63. }
  64. if !d.checkToken(r) {
  65. http.Error(w, "unauthorized", http.StatusUnauthorized)
  66. return
  67. }
  68. if d.Hub == nil {
  69. http.Error(w, "tail hub not configured", http.StatusServiceUnavailable)
  70. return
  71. }
  72. // Company filter (optional)
  73. companyID := strings.TrimSpace(r.URL.Query().Get("company_id"))
  74. // Subscribe BEFORE the upgrade so events that arrive in
  75. // the window between dial-completion and the per-goroutine
  76. // stream-loop startup are not lost. The hub's buffered
  77. // channel (64) absorbs the burst; if the client is too
  78. // slow, the drops counter ticks.
  79. sub := d.Hub.Subscribe(tailhub.Filter{CompanyID: companyID})
  80. // Defer Unsubscribe BEFORE the deferred metric update so
  81. // the gauge observes the post-Unsubscribe subscriber count
  82. // (defers run LIFO).
  83. defer func() {
  84. sub.Unsubscribe()
  85. if d.Metrics != nil {
  86. d.Metrics.TailSubscribers.Set(float64(d.Hub.Stats().Subscribers))
  87. }
  88. }()
  89. if d.Metrics != nil {
  90. d.Metrics.TailSubscribers.Set(float64(d.Hub.Stats().Subscribers))
  91. }
  92. conn, err := upgrader.Upgrade(w, r, nil)
  93. if err != nil {
  94. return
  95. }
  96. // Stream loop. We hold the conn write-locked while sending
  97. // each frame; gorilla's NextWriter handles that. We also
  98. // poll for client-initiated close (close frame or read
  99. // error) to break the loop promptly.
  100. conn.SetReadLimit(512) // we don't read frames from the
  101. // client, but gorilla requires a non-zero read limit.
  102. // 512 bytes is enough for an empty close frame.
  103. stop := make(chan struct{})
  104. go func() {
  105. for {
  106. if _, _, err := conn.NextReader(); err != nil {
  107. close(stop)
  108. return
  109. }
  110. }
  111. }()
  112. for {
  113. select {
  114. case <-stop:
  115. return
  116. case ev, ok := <-sub.C:
  117. if !ok {
  118. return
  119. }
  120. _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
  121. if err := conn.WriteJSON(ev); err != nil {
  122. // Could be a slow client; the drop is already
  123. // counted in sub.Drops.
  124. if d.Metrics != nil {
  125. d.Metrics.TailDropped.WithLabelValues("write_error").Inc()
  126. }
  127. return
  128. }
  129. // Per-subscription drops that happened in the hub
  130. // (channel full). Tick our tail_dropped metric so
  131. // the operator sees the backpressure in Grafana.
  132. //
  133. // We use Add(Drops.Load()) and then reset Drops to
  134. // 0 so each tick reflects the events that have
  135. // happened since the last successful write. The
  136. // counter still increments monotonically (Add is
  137. // monotonic), which is what dashboards want.
  138. if d.Metrics != nil && sub.Drops.Load() > 0 {
  139. dropped := sub.Drops.Load()
  140. sub.Drops.Store(0)
  141. d.Metrics.TailDropped.WithLabelValues("slow_consumer").Add(float64(dropped))
  142. }
  143. }
  144. }
  145. }
  146. // checkToken validates the per-request token. The order is:
  147. // 1. Authorization: Bearer <token>
  148. // 2. X-BA-Tail-Token: <token>
  149. // 3. ?token=<token>
  150. func (d *wsTailDeps) checkToken(r *http.Request) bool {
  151. if h := r.Header.Get("Authorization"); h != "" {
  152. const p = "Bearer "
  153. if strings.HasPrefix(h, p) && strings.TrimPrefix(h, p) == d.Token {
  154. return true
  155. }
  156. }
  157. if h := r.Header.Get("X-BA-Tail-Token"); h != "" && h == d.Token {
  158. return true
  159. }
  160. if q := r.URL.Query().Get("token"); q != "" && q == d.Token {
  161. return true
  162. }
  163. return false
  164. }