feat(push-gateway,msg): redis pubsub backplane for real-time notifications
M7: ADR-040 Redis Pub/Sub as state routing backplane
- push-gateway: remove Kafka consumer, add SSE endpoint
- SSE: subscribe to Redis user:{userId}:notify on connect
- msg: publish notifications to Redis Pub/Sub instead of HTTP push
- docker-compose: remove Kafka env from push-gateway
This commit is contained in:
217
services/push-gateway/internal/sse/handler.go
Normal file
217
services/push-gateway/internal/sse/handler.go
Normal file
@@ -0,0 +1,217 @@
|
||||
// Package sse implements the Server-Sent Events endpoint for real-time
|
||||
// notification delivery.
|
||||
//
|
||||
// M7 (ADR-040): The SSE endpoint replaces direct Kafka mounting with a
|
||||
// Redis Pub/Sub backplane. Each SSE connection dynamically subscribes to
|
||||
// the Redis channel `user:{userId}:notify` for the authenticated user.
|
||||
// When the user disconnects, the subscription is released. This per-connection
|
||||
// subscription model means each realtime-gateway instance only holds Redis
|
||||
// subscriptions for users actively connected to it, enabling horizontal
|
||||
// scaling behind a load balancer.
|
||||
//
|
||||
// Authentication:
|
||||
// - JWT RS256 validated via shared-go/jwks (caching JWKS from iam
|
||||
// /.well-known/jwks.json). DevMode accepts the literal "dev-token"
|
||||
// returning a synthetic dev-user subject.
|
||||
//
|
||||
// SSE format: `data: {json}\n\n` (per the HTML5 Server-Sent Events spec).
|
||||
// Heartbeat comments (`: heartbeat\n\n`) are sent every 30s to keep the
|
||||
// connection alive through proxies.
|
||||
package sse
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/edu-cloud/push-gateway/internal/config"
|
||||
"github.com/edu-cloud/push-gateway/internal/observability"
|
||||
"github.com/edu-cloud/push-gateway/internal/redisclient"
|
||||
"github.com/edu-cloud/shared-go/jwks"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// heartbeatInterval is how often a comment frame is sent to keep the SSE
|
||||
// connection alive through proxies that idle-timeout connections.
|
||||
const heartbeatInterval = 30 * time.Second
|
||||
|
||||
// errResponse is the ActionState-shaped error envelope returned for auth
|
||||
// failures and Redis unavailability.
|
||||
type errResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Error *errBody `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type errBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var (
|
||||
errMissingToken = errors.New("missing token")
|
||||
errInvalidToken = errors.New("invalid token")
|
||||
errMissingUserID = errors.New("missing user id")
|
||||
)
|
||||
|
||||
// Handler owns the SSE endpoint for notification delivery.
|
||||
type Handler struct {
|
||||
cfg *config.Config
|
||||
redis *redisclient.Client
|
||||
jwksFetcher *jwks.Fetcher
|
||||
metrics *observability.Metrics
|
||||
}
|
||||
|
||||
// NewHandler constructs an SSE Handler. cfg supplies JWT auth configuration;
|
||||
// redis may be nil in DevMode (SSE returns 503 when Redis is unavailable).
|
||||
// jwksFetcher is initialized from cfg.JWKSURL when non-empty.
|
||||
func NewHandler(cfg *config.Config, r *redisclient.Client, m *observability.Metrics) *Handler {
|
||||
var fetcher *jwks.Fetcher
|
||||
if cfg.JWKSURL != "" {
|
||||
fetcher = jwks.NewFetcher(cfg.JWKSURL)
|
||||
}
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
redis: r,
|
||||
jwksFetcher: fetcher,
|
||||
metrics: m,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleNotifications is the GET /sse/notifications endpoint. It upgrades the
|
||||
// HTTP connection to a persistent SSE stream, subscribing to the user's
|
||||
// Redis Pub/Sub channel and forwarding messages as SSE data frames.
|
||||
//
|
||||
// Lifecycle:
|
||||
// 1. Authenticate via JWT (query ?token= or Authorization: Bearer header)
|
||||
// 2. Set SSE headers (Content-Type: text/event-stream, no-cache, etc.)
|
||||
// 3. SUBSCRIBE to Redis channel `user:{userId}:notify`
|
||||
// 4. Forward each Redis message as `data: {json}\n\n`
|
||||
// 5. Send heartbeat comment every 30s
|
||||
// 6. On client disconnect or context cancel: UNSUBSCRIBE (deferred pubsub.Close)
|
||||
func (h *Handler) HandleNotifications(c *gin.Context) {
|
||||
userID, err := h.authenticate(c)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, errResponse{
|
||||
Success: false,
|
||||
Error: &errBody{Code: "SSE_UNAUTHORIZED", Message: err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if h.redis == nil {
|
||||
c.AbortWithStatusJSON(http.StatusServiceUnavailable, errResponse{
|
||||
Success: false,
|
||||
Error: &errBody{Code: "SSE_REDIS_UNAVAILABLE", Message: "Redis not configured"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// SSE response headers.
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no") // Disable nginx buffering.
|
||||
|
||||
// Per-connection Redis subscription to the user's notify channel.
|
||||
// go-redis PubSub is goroutine-safe per instance; each Subscribe call
|
||||
// creates an independent PubSub that is closed on connection end.
|
||||
channel := redisclient.NotifyChannel(userID)
|
||||
pubsub := h.redis.Redis().Subscribe(c.Request.Context(), channel)
|
||||
defer pubsub.Close()
|
||||
|
||||
msgCh := pubsub.Channel()
|
||||
ticker := time.NewTicker(heartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
observability.Logger().Info("sse connection opened",
|
||||
"user_id", userID, "channel", channel)
|
||||
|
||||
c.Stream(func(w io.Writer) bool {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return false
|
||||
case msg, ok := <-msgCh:
|
||||
if !ok {
|
||||
// Redis subscription closed (e.g. Redis connection lost).
|
||||
observability.Logger().Warn("sse redis subscription closed",
|
||||
"user_id", userID)
|
||||
return false
|
||||
}
|
||||
// Forward Redis payload as SSE data frame: `data: {json}\n\n`
|
||||
fmt.Fprintf(w, "data: %s\n\n", msg.Payload)
|
||||
h.metrics.IncPushed("sse.notification", "delivered")
|
||||
return true
|
||||
case <-ticker.C:
|
||||
// SSE comment frame keeps the connection alive through proxies.
|
||||
fmt.Fprintf(w, ": heartbeat\n\n")
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
observability.Logger().Info("sse connection closed", "user_id", userID)
|
||||
}
|
||||
|
||||
// authenticate validates the JWT token. Production uses RS256 via shared-go/jwks;
|
||||
// DevMode accepts the literal "dev-token" string. The token may be passed via
|
||||
// the ?token= query parameter or the Authorization: Bearer header.
|
||||
func (h *Handler) authenticate(c *gin.Context) (string, error) {
|
||||
tokenStr := c.Query("token")
|
||||
if tokenStr == "" {
|
||||
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||||
tokenStr = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
}
|
||||
if tokenStr == "" {
|
||||
return "", errMissingToken
|
||||
}
|
||||
|
||||
// DevMode shortcut: dev-token maps to a synthetic local user.
|
||||
if h.cfg.DevMode && tokenStr == "dev-token" {
|
||||
return "dev-user", nil
|
||||
}
|
||||
|
||||
// RS256 via shared-go/jwks (preferred, requires iam /.well-known/jwks.json).
|
||||
if h.jwksFetcher != nil {
|
||||
claims, err := h.jwksFetcher.ValidateToken(tokenStr)
|
||||
if err != nil {
|
||||
return "", errInvalidToken
|
||||
}
|
||||
if claims.UserID == "" {
|
||||
return "", errMissingUserID
|
||||
}
|
||||
return claims.UserID, nil
|
||||
}
|
||||
|
||||
// DevMode without JWKS: fall back to HS256 with the configured secret.
|
||||
if h.cfg.DevMode && h.cfg.JWTSecret != "" {
|
||||
return validateHS256(tokenStr, h.cfg.JWTSecret)
|
||||
}
|
||||
return "", errInvalidToken
|
||||
}
|
||||
|
||||
// validateHS256 parses and validates an HS256 JWT signed with secret. Used
|
||||
// only in DevMode when no JWKS endpoint is configured (local integration
|
||||
// tests). Returns the user_id claim on success.
|
||||
func validateHS256(tokenStr, secret string) (string, error) {
|
||||
claims := &jwks.Claims{}
|
||||
parsed, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("sse: unexpected signing method %v", t.Header["alg"])
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", errInvalidToken
|
||||
}
|
||||
if !parsed.Valid {
|
||||
return "", errInvalidToken
|
||||
}
|
||||
if claims.UserID == "" {
|
||||
return "", errMissingUserID
|
||||
}
|
||||
return claims.UserID, nil
|
||||
}
|
||||
Reference in New Issue
Block a user