ws.go 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. // WebSocket ingest endpoint for ingestd (M5). Implements the
  2. // seven protection layers from SPEC §22 in the same order as
  3. // HTTP/MQTT, with layer 2 (per-IP concurrency cap) being new
  4. // in M5.
  5. //
  6. // Endpoint:
  7. //
  8. // GET /v1/ingest/ws HTTP Upgrade → WebSocket
  9. //
  10. // Protocol:
  11. //
  12. // client → server: {"api_key": "company:source:secret"} (auth frame)
  13. // server → client: {"ready": true} (auth ack)
  14. // client → server: {"alert": {...}, "auth": "t=...,v1=..."} (alert frame)
  15. // server → client: {"alert_id": "...", "dedupe_count": N} (per-alert ack)
  16. // server → client: {"error": "reason", "detail": "..."} (rejection)
  17. //
  18. // One WS connection = one (company_id, source_id) pair. After
  19. // the auth frame, the server only accepts alert frames for the
  20. // authenticated source. A second auth frame on the same
  21. // connection is a protocol error → close 1008.
  22. //
  23. // Why text frames: easier to debug with wscat / websocat. The
  24. // JSON shape is identical to the HTTP POST body. Binary frames
  25. // are rejected as a protocol error.
  26. package main
  27. import (
  28. "encoding/json"
  29. "net/http"
  30. "strings"
  31. "time"
  32. "git3.techno-world.net/lrosales/broad-announce/internal/concurrency"
  33. "github.com/gorilla/websocket"
  34. )
  35. // upgrader is shared by /v1/ingest/ws and /v1/tail/ws. We allow
  36. // any Origin in dev; the production cutover uses a same-origin
  37. // check.
  38. var upgrader = websocket.Upgrader{
  39. ReadBufferSize: 4 << 10, // 4 KB
  40. WriteBufferSize: 4 << 10,
  41. // Origins: enforce same-origin or empty Origin in prod.
  42. // M5 dev: allow any.
  43. CheckOrigin: func(r *http.Request) bool { return true },
  44. }
  45. // wsIngestDeps is the WS-handler-scoped wrapper. The body of
  46. // the handler is still a single ProcessAlert call; this struct
  47. // only adds the WS-specific bits (PerIP gate, max frame size,
  48. // per-conn read deadline).
  49. type wsIngestDeps struct {
  50. processDeps
  51. // PerIP is the shared per-IP concurrency cap. Incremented
  52. // on upgrade success; decremented on close.
  53. PerIP *concurrency.PerIP
  54. // MaxFrameBytes is the per-frame payload cap (SPEC §22
  55. // layer 1). Default 256 KB; matches HTTP's MaxBytes.
  56. MaxFrameBytes int64
  57. // ReadDeadline is the per-read deadline for the auth frame
  58. // and every subsequent alert frame. A slow client gets a
  59. // 1008 close.
  60. ReadDeadline time.Duration
  61. // WriteDeadline is the per-write deadline for the auth
  62. // ack and per-alert acks.
  63. WriteDeadline time.Duration
  64. }
  65. // authFrame is the first frame sent by the client. It is the
  66. // same shape as the HTTP `X-BA-Key` header but as a JSON object
  67. // so the server can also accept a per-conn token in the future.
  68. type wsAuthFrame struct {
  69. APIKey string `json:"api_key"`
  70. }
  71. // wsAckFrame is the per-alert ack.
  72. type wsAckFrame struct {
  73. AlertID string `json:"alert_id,omitempty"`
  74. DedupeCount uint32 `json:"dedupe_count,omitempty"`
  75. Error string `json:"error,omitempty"`
  76. Detail string `json:"detail,omitempty"`
  77. // Transport is the per-alert "result" label, useful for
  78. // tests that want to assert which metric advanced.
  79. Result string `json:"result,omitempty"`
  80. }
  81. // RegisterWSRoutes wires the WS endpoints onto the given mux.
  82. func RegisterWSRoutes(mux *http.ServeMux, d *wsIngestDeps, tail *wsTailDeps) {
  83. mux.HandleFunc("GET /v1/ingest/ws", d.handleIngest)
  84. if tail != nil {
  85. mux.HandleFunc("GET /v1/tail/ws", tail.handleTail)
  86. }
  87. }
  88. // handleIngest is the WS upgrade handler for /v1/ingest/ws.
  89. func (d *wsIngestDeps) handleIngest(w http.ResponseWriter, r *http.Request) {
  90. ip := clientIP(r)
  91. // Layer 2 — per-IP concurrency cap. Acquire BEFORE the
  92. // upgrade so a DoS'd client doesn't even reach the WS
  93. // handshake. We release on close (any reason).
  94. if !d.PerIP.Acquire(ip) {
  95. d.Metrics.ConnectionRejected.WithLabelValues("ws").Inc()
  96. d.Metrics.WSConnections.WithLabelValues("closed_per_ip_cap").Inc()
  97. http.Error(w, "per-IP connection cap exceeded", http.StatusTooManyRequests)
  98. return
  99. }
  100. conn, err := upgrader.Upgrade(w, r, nil)
  101. if err != nil {
  102. d.PerIP.Release(ip)
  103. d.Logger.Warn("ws upgrade", "err", err, "ip", ip)
  104. return
  105. }
  106. // From here on, every exit path must call
  107. // defer d.PerIP.Release(ip).
  108. defer d.PerIP.Release(ip)
  109. d.Metrics.WSConnections.WithLabelValues("open").Inc()
  110. // Auth frame. 5-second deadline: a client that dials and
  111. // doesn't send the auth frame fast enough is treated as
  112. // unauthenticated and gets a 1008 close.
  113. _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
  114. _, msg, err := conn.ReadMessage()
  115. if err != nil {
  116. d.Metrics.WSConnections.WithLabelValues("closed_unauth").Inc()
  117. _ = conn.Close()
  118. return
  119. }
  120. var auth wsAuthFrame
  121. if err := json.Unmarshal(msg, &auth); err != nil || auth.APIKey == "" {
  122. d.Metrics.WSConnections.WithLabelValues("closed_unauth").Inc()
  123. _ = conn.WriteJSON(wsAckFrame{Error: "unauthorized", Detail: "bad auth frame"})
  124. _ = conn.Close()
  125. return
  126. }
  127. parts := strings.SplitN(auth.APIKey, ":", 3)
  128. if len(parts) != 3 {
  129. d.Metrics.WSConnections.WithLabelValues("closed_unauth").Inc()
  130. _ = conn.WriteJSON(wsAckFrame{Error: "unauthorized", Detail: "api_key must be company:source:secret"})
  131. _ = conn.Close()
  132. return
  133. }
  134. companyID, sourceID, secret := parts[0], parts[1], parts[2]
  135. src, ok := d.Sources[companyID+":"+sourceID]
  136. if !ok || string(src.HMACSecret) != secret {
  137. d.Metrics.WSConnections.WithLabelValues("closed_unauth").Inc()
  138. _ = conn.WriteJSON(wsAckFrame{Error: "unauthorized", Detail: "unknown source or wrong secret"})
  139. _ = conn.Close()
  140. return
  141. }
  142. // Replace the in-process processDeps.Sources with a single-
  143. // source map so a misrouted alert on this conn (e.g. one
  144. // with the wrong company_id) is rejected with unknown_source
  145. // — same as HTTP and MQTT.
  146. scoped := d.processDeps
  147. scoped.Sources = map[string]SourceConfig{companyID + ":" + sourceID: {
  148. CompanyID: companyID,
  149. SourceID: sourceID,
  150. HMACSecret: src.HMACSecret,
  151. RateLimitPerSec: src.RateLimitPerSec,
  152. AllowedTargets: src.AllowedTargets,
  153. }}
  154. // Auth ack
  155. _ = conn.SetWriteDeadline(time.Now().Add(d.WriteDeadline))
  156. if err := conn.WriteJSON(wsAckFrame{Result: "ready"}); err != nil {
  157. d.Metrics.WSConnections.WithLabelValues("closed_protocol_error").Inc()
  158. _ = conn.Close()
  159. return
  160. }
  161. // Loop. One frame per alert. We reset the read deadline on
  162. // every successful read; the conn-level deadline is
  163. // ReadDeadline from the deps.
  164. conn.SetReadLimit(d.MaxFrameBytes)
  165. closeState := "closed_clean"
  166. for {
  167. _ = conn.SetReadDeadline(time.Now().Add(d.ReadDeadline))
  168. mt, body, err := conn.ReadMessage()
  169. if err != nil {
  170. if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
  171. closeState = "closed_clean"
  172. } else {
  173. closeState = "closed_protocol_error"
  174. }
  175. break
  176. }
  177. if mt != websocket.TextMessage {
  178. // Binary frames are a protocol error — M5 is text-only.
  179. _ = conn.WriteJSON(wsAckFrame{Error: "protocol_error", Detail: "binary frames not supported"})
  180. closeState = "closed_protocol_error"
  181. break
  182. }
  183. // Layer 1: MaxFrameBytes is enforced by SetReadLimit
  184. // above; gorilla returns an error if the frame exceeds
  185. // it. The error path closes the conn.
  186. // Sniff envelope (same as MQTT). Accept either
  187. // {alert, auth} envelope or bare alert body.
  188. alertBody, sigHeader := body, ""
  189. if len(body) > 0 && body[0] == '{' {
  190. var env struct {
  191. Alert json.RawMessage `json:"alert"`
  192. Auth string `json:"auth"`
  193. }
  194. if err := json.Unmarshal(body, &env); err == nil && len(env.Alert) > 0 {
  195. alertBody = env.Alert
  196. sigHeader = env.Auth
  197. }
  198. }
  199. d.Metrics.WSMessages.WithLabelValues("received").Inc()
  200. res := scoped.ProcessAlert(r.Context(), alertBody, sigHeader)
  201. _ = conn.SetWriteDeadline(time.Now().Add(d.WriteDeadline))
  202. if !res.Accepted {
  203. d.Metrics.WSMessages.WithLabelValues(res.RejectReason).Inc()
  204. _ = conn.WriteJSON(wsAckFrame{Error: res.RejectReason, Detail: res.Detail, Result: res.RejectReason})
  205. continue
  206. }
  207. d.Metrics.WSMessages.WithLabelValues("accepted").Inc()
  208. if !res.IsNew {
  209. d.Metrics.WSMessages.WithLabelValues("deduped").Inc()
  210. }
  211. _ = conn.WriteJSON(wsAckFrame{AlertID: res.AlertID, DedupeCount: res.DedupeCount, Result: "accepted"})
  212. _ = conn.SetWriteDeadline(time.Time{}) // reset
  213. }
  214. d.Metrics.WSConnections.WithLabelValues(closeState).Inc()
  215. _ = conn.Close()
  216. }
  217. // clientIP returns the best-effort source IP for r. We trust
  218. // the X-Forwarded-For header only when behind a known proxy —
  219. // in M5 dev, we use r.RemoteAddr. The production cutover reads
  220. // the proxy chain from config.
  221. func clientIP(r *http.Request) string {
  222. // gorilla's RemoteAddr is "host:port"; strip the port.
  223. addr := r.RemoteAddr
  224. if i := strings.LastIndex(addr, ":"); i > 0 {
  225. // Handle IPv6 "::1:80" — r.RemoteAddr can be "[::1]:80".
  226. if addr[0] == '[' {
  227. if j := strings.Index(addr, "]"); j > 0 {
  228. return addr[1:j]
  229. }
  230. }
  231. return addr[:i]
  232. }
  233. return addr
  234. }