// WebSocket ingest endpoint for ingestd (M5). Implements the // seven protection layers from SPEC §22 in the same order as // HTTP/MQTT, with layer 2 (per-IP concurrency cap) being new // in M5. // // Endpoint: // // GET /v1/ingest/ws HTTP Upgrade → WebSocket // // Protocol: // // client → server: {"api_key": "company:source:secret"} (auth frame) // server → client: {"ready": true} (auth ack) // client → server: {"alert": {...}, "auth": "t=...,v1=..."} (alert frame) // server → client: {"alert_id": "...", "dedupe_count": N} (per-alert ack) // server → client: {"error": "reason", "detail": "..."} (rejection) // // One WS connection = one (company_id, source_id) pair. After // the auth frame, the server only accepts alert frames for the // authenticated source. A second auth frame on the same // connection is a protocol error → close 1008. // // Why text frames: easier to debug with wscat / websocat. The // JSON shape is identical to the HTTP POST body. Binary frames // are rejected as a protocol error. package main import ( "encoding/json" "net/http" "strings" "time" "git3.techno-world.net/lrosales/broad-announce/internal/concurrency" "github.com/gorilla/websocket" ) // upgrader is shared by /v1/ingest/ws and /v1/tail/ws. We allow // any Origin in dev; the production cutover uses a same-origin // check. var upgrader = websocket.Upgrader{ ReadBufferSize: 4 << 10, // 4 KB WriteBufferSize: 4 << 10, // Origins: enforce same-origin or empty Origin in prod. // M5 dev: allow any. CheckOrigin: func(r *http.Request) bool { return true }, } // wsIngestDeps is the WS-handler-scoped wrapper. The body of // the handler is still a single ProcessAlert call; this struct // only adds the WS-specific bits (PerIP gate, max frame size, // per-conn read deadline). type wsIngestDeps struct { processDeps // PerIP is the shared per-IP concurrency cap. Incremented // on upgrade success; decremented on close. PerIP *concurrency.PerIP // MaxFrameBytes is the per-frame payload cap (SPEC §22 // layer 1). Default 256 KB; matches HTTP's MaxBytes. MaxFrameBytes int64 // ReadDeadline is the per-read deadline for the auth frame // and every subsequent alert frame. A slow client gets a // 1008 close. ReadDeadline time.Duration // WriteDeadline is the per-write deadline for the auth // ack and per-alert acks. WriteDeadline time.Duration } // authFrame is the first frame sent by the client. It is the // same shape as the HTTP `X-BA-Key` header but as a JSON object // so the server can also accept a per-conn token in the future. type wsAuthFrame struct { APIKey string `json:"api_key"` } // wsAckFrame is the per-alert ack. type wsAckFrame struct { AlertID string `json:"alert_id,omitempty"` DedupeCount uint32 `json:"dedupe_count,omitempty"` Error string `json:"error,omitempty"` Detail string `json:"detail,omitempty"` // Transport is the per-alert "result" label, useful for // tests that want to assert which metric advanced. Result string `json:"result,omitempty"` } // RegisterWSRoutes wires the WS endpoints onto the given mux. func RegisterWSRoutes(mux *http.ServeMux, d *wsIngestDeps, tail *wsTailDeps) { mux.HandleFunc("GET /v1/ingest/ws", d.handleIngest) if tail != nil { mux.HandleFunc("GET /v1/tail/ws", tail.handleTail) } } // handleIngest is the WS upgrade handler for /v1/ingest/ws. func (d *wsIngestDeps) handleIngest(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) // Layer 2 — per-IP concurrency cap. Acquire BEFORE the // upgrade so a DoS'd client doesn't even reach the WS // handshake. We release on close (any reason). if !d.PerIP.Acquire(ip) { d.Metrics.ConnectionRejected.WithLabelValues("ws").Inc() d.Metrics.WSConnections.WithLabelValues("closed_per_ip_cap").Inc() http.Error(w, "per-IP connection cap exceeded", http.StatusTooManyRequests) return } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { d.PerIP.Release(ip) d.Logger.Warn("ws upgrade", "err", err, "ip", ip) return } // From here on, every exit path must call // defer d.PerIP.Release(ip). defer d.PerIP.Release(ip) d.Metrics.WSConnections.WithLabelValues("open").Inc() // Auth frame. 5-second deadline: a client that dials and // doesn't send the auth frame fast enough is treated as // unauthenticated and gets a 1008 close. _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) _, msg, err := conn.ReadMessage() if err != nil { d.Metrics.WSConnections.WithLabelValues("closed_unauth").Inc() _ = conn.Close() return } var auth wsAuthFrame if err := json.Unmarshal(msg, &auth); err != nil || auth.APIKey == "" { d.Metrics.WSConnections.WithLabelValues("closed_unauth").Inc() _ = conn.WriteJSON(wsAckFrame{Error: "unauthorized", Detail: "bad auth frame"}) _ = conn.Close() return } parts := strings.SplitN(auth.APIKey, ":", 3) if len(parts) != 3 { d.Metrics.WSConnections.WithLabelValues("closed_unauth").Inc() _ = conn.WriteJSON(wsAckFrame{Error: "unauthorized", Detail: "api_key must be company:source:secret"}) _ = conn.Close() return } companyID, sourceID, secret := parts[0], parts[1], parts[2] src, ok := d.Sources[companyID+":"+sourceID] if !ok || string(src.HMACSecret) != secret { d.Metrics.WSConnections.WithLabelValues("closed_unauth").Inc() _ = conn.WriteJSON(wsAckFrame{Error: "unauthorized", Detail: "unknown source or wrong secret"}) _ = conn.Close() return } // Replace the in-process processDeps.Sources with a single- // source map so a misrouted alert on this conn (e.g. one // with the wrong company_id) is rejected with unknown_source // — same as HTTP and MQTT. scoped := d.processDeps scoped.Sources = map[string]SourceConfig{companyID + ":" + sourceID: { CompanyID: companyID, HMACSecret: src.HMACSecret, RateLimitPerSec: src.RateLimitPerSec, AllowedTargets: src.AllowedTargets, }} // Auth ack _ = conn.SetWriteDeadline(time.Now().Add(d.WriteDeadline)) if err := conn.WriteJSON(wsAckFrame{Result: "ready"}); err != nil { d.Metrics.WSConnections.WithLabelValues("closed_protocol_error").Inc() _ = conn.Close() return } // Loop. One frame per alert. We reset the read deadline on // every successful read; the conn-level deadline is // ReadDeadline from the deps. conn.SetReadLimit(d.MaxFrameBytes) closeState := "closed_clean" for { _ = conn.SetReadDeadline(time.Now().Add(d.ReadDeadline)) mt, body, err := conn.ReadMessage() if err != nil { if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { closeState = "closed_clean" } else { closeState = "closed_protocol_error" } break } if mt != websocket.TextMessage { // Binary frames are a protocol error — M5 is text-only. _ = conn.WriteJSON(wsAckFrame{Error: "protocol_error", Detail: "binary frames not supported"}) closeState = "closed_protocol_error" break } // Layer 1: MaxFrameBytes is enforced by SetReadLimit // above; gorilla returns an error if the frame exceeds // it. The error path closes the conn. // Sniff envelope (same as MQTT). Accept either // {alert, auth} envelope or bare alert body. alertBody, sigHeader := body, "" if len(body) > 0 && body[0] == '{' { var env struct { Alert json.RawMessage `json:"alert"` Auth string `json:"auth"` } if err := json.Unmarshal(body, &env); err == nil && len(env.Alert) > 0 { alertBody = env.Alert sigHeader = env.Auth } } d.Metrics.WSMessages.WithLabelValues("received").Inc() res := scoped.ProcessAlert(r.Context(), alertBody, sigHeader) _ = conn.SetWriteDeadline(time.Now().Add(d.WriteDeadline)) if !res.Accepted { d.Metrics.WSMessages.WithLabelValues(res.RejectReason).Inc() _ = conn.WriteJSON(wsAckFrame{Error: res.RejectReason, Detail: res.Detail, Result: res.RejectReason}) continue } d.Metrics.WSMessages.WithLabelValues("accepted").Inc() if !res.IsNew { d.Metrics.WSMessages.WithLabelValues("deduped").Inc() } _ = conn.WriteJSON(wsAckFrame{AlertID: res.AlertID, DedupeCount: res.DedupeCount, Result: "accepted"}) _ = conn.SetWriteDeadline(time.Time{}) // reset } d.Metrics.WSConnections.WithLabelValues(closeState).Inc() _ = conn.Close() } // clientIP returns the best-effort source IP for r. We trust // the X-Forwarded-For header only when behind a known proxy — // in M5 dev, we use r.RemoteAddr. The production cutover reads // the proxy chain from config. func clientIP(r *http.Request) string { // gorilla's RemoteAddr is "host:port"; strip the port. addr := r.RemoteAddr if i := strings.LastIndex(addr, ":"); i > 0 { // Handle IPv6 "::1:80" — r.RemoteAddr can be "[::1]:80". if addr[0] == '[' { if j := strings.Index(addr, "]"); j > 0 { return addr[1:j] } } return addr[:i] } return addr }