Files
graphql-monitoring-proxy/websocket.go
lukaszraczylo ccc25426dd fix(websocket): join ping goroutine and unify keepalive deadlines
The handler returned while the ping goroutine could still write to
a connection that gofiber had recycled into its pool. This caused
a nil dereference panic or a ping on an unrelated socket.

Join the ping goroutine through a done channel before the handler
returns. Compute effective keepalive values once in the
constructor, clamp pongTimeout to at least pingInterval with one
warning, and pass the effective values to both proxy directions.
A negative interval or timeout disables keepalive. New test drives
the real HandleWebSocket wiring.
2026-08-14 00:01:03 +01:00

668 lines
22 KiB
Go

package main
import (
"context"
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/goccy/go-json"
"github.com/gofiber/contrib/v3/websocket"
"github.com/gofiber/fiber/v3"
gorillaws "github.com/gorilla/websocket"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
)
// WebSocketProxy handles WebSocket proxying for GraphQL subscriptions
type WebSocketProxy struct {
logger *libpack_logger.Logger
monitoring *libpack_monitoring.MetricsSetup
backendURL string
enabled bool
pingInterval time.Duration
pongTimeout time.Duration
maxMessageSize int64
// effectivePingInterval/effectivePongTimeout/keepaliveEnabled are the
// defaulted-and-clamped keepalive values, computed once in
// NewWebSocketProxy (see computeEffectiveKeepalive) and reused by every
// connection via effectiveKeepalive. Computing them once means any clamp
// warning is logged once per proxy instead of once per connection.
effectivePingInterval time.Duration
effectivePongTimeout time.Duration
keepaliveEnabled bool
// Statistics
activeConnections atomic.Int64
totalConnections atomic.Int64
messagesSent atomic.Int64
messagesReceived atomic.Int64
errors atomic.Int64
}
// WebSocketConfig holds WebSocket configuration
type WebSocketConfig struct {
Enabled bool
PingInterval time.Duration
PongTimeout time.Duration
MaxMessageSize int64
}
// NewWebSocketProxy creates a new WebSocket proxy
func NewWebSocketProxy(backendURL string, config WebSocketConfig, logger *libpack_logger.Logger, monitoring *libpack_monitoring.MetricsSetup) *WebSocketProxy {
if config.PingInterval == 0 {
config.PingInterval = 30 * time.Second
}
if config.PongTimeout == 0 {
config.PongTimeout = 60 * time.Second
}
if config.MaxMessageSize == 0 {
config.MaxMessageSize = 512 * 1024 // 512KB default
}
wsp := &WebSocketProxy{
logger: logger,
monitoring: monitoring,
backendURL: backendURL,
enabled: config.Enabled,
pingInterval: config.PingInterval,
pongTimeout: config.PongTimeout,
maxMessageSize: config.MaxMessageSize,
}
wsp.effectivePingInterval, wsp.effectivePongTimeout, wsp.keepaliveEnabled =
computeEffectiveKeepalive(wsp.pingInterval, wsp.pongTimeout, logger)
if logger != nil && config.Enabled {
logger.Info(&libpack_logger.LogMessage{
Message: "WebSocket proxy enabled",
Pairs: map[string]any{
"backend_url": backendURL,
"ping_interval": config.PingInterval,
"max_message_size": config.MaxMessageSize,
},
})
}
return wsp
}
// HandleWebSocket upgrades the connection and proxies WebSocket traffic
func (wsp *WebSocketProxy) HandleWebSocket(c fiber.Ctx) error {
if !wsp.enabled {
return fiber.NewError(fiber.StatusNotImplemented, "WebSocket support is disabled")
}
// Check if this is a WebSocket upgrade request
if !websocket.IsWebSocketUpgrade(c) {
return fiber.NewError(fiber.StatusUpgradeRequired, "WebSocket upgrade required")
}
// Capture headers from the upgrade request to forward to backend
headers := make(http.Header)
var subprotocols []string
for key, value := range c.Request().Header.All() {
keyStr := string(key)
// Capture subprotocol separately
if keyStr == "Sec-Websocket-Protocol" || keyStr == "Sec-WebSocket-Protocol" {
subprotocols = append(subprotocols, string(value))
}
// Forward important headers including WebSocket subprotocol
// Skip only connection-establishment headers that will be regenerated
if keyStr != "Connection" && keyStr != "Upgrade" &&
keyStr != "Sec-Websocket-Key" && keyStr != "Sec-Websocket-Version" &&
keyStr != "Sec-Websocket-Extensions" {
headers.Add(keyStr, string(value))
}
}
// Configure WebSocket with subprotocol support
config := websocket.Config{
Subprotocols: subprotocols,
}
return websocket.New(func(clientConn *websocket.Conn) {
// Use background context for long-lived WebSocket connections
// The original request context expires after the upgrade
wsp.handleConnection(context.Background(), clientConn, headers)
}, config)(c)
}
// computeEffectiveKeepalive derives the ping period and pong read-deadline
// timeout a WebSocket proxy should use, plus whether keepalive is enabled at
// all, from the (already-defaulted) configured pingInterval/pongTimeout. It
// runs once, from NewWebSocketProxy, and the result is stored on the proxy
// so effectiveKeepalive can hand it to every connection without
// recomputing - and without re-logging any clamp warning - per connection.
//
// Operators can opt out of keepalive entirely by configuring a NEGATIVE
// PingInterval or PongTimeout (WEBSOCKET_PING_INTERVAL / _PONG_TIMEOUT) - no
// ping goroutine runs and no read deadline is set, matching the
// pre-keepalive behaviour of blocking on ReadMessage until the peer or
// network closes the connection. A value of exactly zero does NOT disable
// keepalive here: NewWebSocketProxy rewrites a zero PingInterval/PongTimeout
// to the 30s/60s defaults before this function ever sees them (and main.go's
// env parsing defaults PING_INTERVAL/PONG_TIMEOUT to 30/60 for the same
// reason), so only a negative value can reach this function as a disable.
//
// When enabled, pongTimeout is clamped to at least 1x pingInterval. The
// ping goroutine's ticker actually fires at pingInterval/2 (two ping
// attempts per configured interval), so a peer already sees a ping well
// before one full pingInterval elapses; requiring pongTimeout >= pingInterval
// keeps that margin without forcing an operator who explicitly configures a
// tighter timeout to accept the old 2x floor (explicit override wins).
func computeEffectiveKeepalive(pingInterval, pongTimeout time.Duration, logger *libpack_logger.Logger) (effectivePingInterval, effectivePongTimeout time.Duration, enabled bool) {
if pingInterval <= 0 || pongTimeout <= 0 {
return 0, 0, false
}
if pongTimeout < pingInterval {
if logger != nil {
logger.Warn(&libpack_logger.LogMessage{
Message: "WebSocket pong_timeout too low for ping_interval; clamping to avoid reaping idle connections",
Pairs: map[string]any{
"configured_ping_interval": pingInterval,
"configured_pong_timeout": pongTimeout,
"effective_pong_timeout": pingInterval,
},
})
}
pongTimeout = pingInterval
}
return pingInterval, pongTimeout, true
}
// effectiveKeepalive returns the ping period and pong read-deadline timeout
// this proxy should use, plus whether keepalive is enabled at all. It is the
// single source of truth for these values: handleConnection calls it once
// per connection and passes the result to both the initial deadline setup
// and every deadline-refresh path, so they can never disagree. The values
// themselves are computed once per proxy, in NewWebSocketProxy - see
// computeEffectiveKeepalive for the opt-out and clamping rules.
func (wsp *WebSocketProxy) effectiveKeepalive() (pingInterval, pongTimeout time.Duration, enabled bool) {
return wsp.effectivePingInterval, wsp.effectivePongTimeout, wsp.keepaliveEnabled
}
// handleConnection manages a single WebSocket connection
func (wsp *WebSocketProxy) handleConnection(ctx context.Context, clientConn *websocket.Conn, headers http.Header) {
connectionID := fmt.Sprintf("%p", clientConn)
startTime := time.Now()
wsp.activeConnections.Add(1)
wsp.totalConnections.Add(1)
defer wsp.activeConnections.Add(-1)
if wsp.logger != nil {
wsp.logger.Info(&libpack_logger.LogMessage{
Message: "WebSocket connection established",
Pairs: map[string]any{
"connection_id": connectionID,
"active_connections": wsp.activeConnections.Load(),
},
})
}
// Set message size limit
clientConn.SetReadLimit(wsp.maxMessageSize)
// Read first message to extract authentication from connection_init payload
// This bridges the gap between clients that send auth in payload vs Hasura expecting it in HTTP headers
messageType, message, err := clientConn.ReadMessage()
if err != nil {
wsp.errors.Add(1)
if wsp.logger != nil {
wsp.logger.Error(&libpack_logger.LogMessage{
Message: "Failed to read first message from client",
Pairs: map[string]any{
"connection_id": connectionID,
"error": err.Error(),
},
})
}
_ = clientConn.Close() // Best-effort cleanup
return
}
// Try to extract headers from connection_init payload (for GraphQL WebSocket protocols)
enrichedHeaders := wsp.extractAuthFromPayload(message, headers)
// Connect to backend WebSocket with enriched headers
backendConn, err := wsp.dialBackend(ctx, enrichedHeaders)
if err != nil {
wsp.errors.Add(1)
if wsp.logger != nil {
wsp.logger.Error(&libpack_logger.LogMessage{
Message: "Failed to connect to backend WebSocket",
Pairs: map[string]any{
"connection_id": connectionID,
"error": err.Error(),
},
})
}
_ = clientConn.Close() // Best-effort cleanup
return
}
defer func() { _ = backendConn.Close() }() // Best-effort cleanup
// Forward the first message (connection_init) to backend
if err := backendConn.WriteMessage(messageType, message); err != nil {
wsp.errors.Add(1)
if wsp.logger != nil {
wsp.logger.Error(&libpack_logger.LogMessage{
Message: "Failed to forward connection_init to backend",
Pairs: map[string]any{
"connection_id": connectionID,
"error": err.Error(),
},
})
}
return
}
if wsp.logger != nil {
wsp.logger.Debug(&libpack_logger.LogMessage{
Message: "Backend WebSocket connection established",
Pairs: map[string]any{
"connection_id": connectionID,
"subprotocol": backendConn.Subprotocol(),
"has_authorization": headers.Get("Authorization") != "",
},
})
}
// Bound messages read from the backend as well, so a large or
// misbehaving backend cannot force us to forward an unbounded frame to the
// client (the client-side limit only bounds what the client can send us).
backendConn.SetReadLimit(wsp.maxMessageSize)
// Keepalive: wire the configured (previously unused) PingInterval /
// PongTimeout so a half-open peer (TCP still open but silent) is reaped
// instead of leaving both proxy goroutines (and this handler) blocked in
// ReadMessage forever. We ping both directions and enforce a read
// deadline on each side that is refreshed by real traffic or a pong, so
// an active connection is never closed while an idle one is cleaned up.
// See effectiveKeepalive for the opt-out and clamping rules; the values
// it returns are threaded through unchanged to every deadline-refresh
// path below so they can never disagree.
pingInterval, pongTimeout, keepaliveEnabled := wsp.effectiveKeepalive()
if keepaliveEnabled {
_ = clientConn.SetReadDeadline(time.Now().Add(pongTimeout))
clientConn.SetPongHandler(func(string) error {
return clientConn.SetReadDeadline(time.Now().Add(pongTimeout))
})
_ = backendConn.SetReadDeadline(time.Now().Add(pongTimeout))
backendConn.SetPongHandler(func(string) error {
return backendConn.SetReadDeadline(time.Now().Add(pongTimeout))
})
}
// Ping both peers periodically. WriteControl is safe to call concurrently
// with WriteMessage, so this goroutine does not contend with the two
// forwarding directions. Both libraries use the standard WebSocket
// opcode value for a ping (9).
//
// pingDone is closed when the goroutine returns. gofiber's contrib/v3
// websocket recycles clientConn into a sync.Pool (nilling its underlying
// conn) the instant this function returns, so the goroutine MUST be
// joined (cancelPing + <-pingDone below) before handleConnection
// returns - otherwise a still-running WriteControl call can nil-deref
// (crashing the process, since this goroutine has no recover) or write a
// stray ping onto an unrelated, newly-recycled connection.
pingCtx, cancelPing := context.WithCancel(ctx)
pingDone := make(chan struct{})
if keepaliveEnabled {
go func() {
defer close(pingDone)
ticker := time.NewTicker(pingInterval / 2)
defer ticker.Stop()
for {
select {
case <-pingCtx.Done():
return
case <-ticker.C:
writeDeadline := time.Now().Add(10 * time.Second)
_ = clientConn.WriteControl(gorillaws.PingMessage, nil, writeDeadline)
_ = backendConn.WriteControl(gorillaws.PingMessage, nil, writeDeadline)
}
}
}()
} else {
close(pingDone)
}
// Set up bidirectional proxying
var wg sync.WaitGroup
wg.Add(2)
// Client -> Backend
go func() {
defer wg.Done()
// When the client side finishes (e.g. the client disconnected), close
// the backend so the reverse goroutine's blocked read returns instead
// of leaking the goroutine and holding the backend connection open.
defer func() { _ = backendConn.Close() }()
wsp.proxyClientToBackend(ctx, clientConn, backendConn, connectionID, pongTimeout)
}()
// Backend -> Client
go func() {
defer wg.Done()
// Symmetric teardown: when the backend side finishes, close the
// client so the client-direction goroutine isn't left blocked either.
defer func() { _ = clientConn.Close() }()
wsp.proxyBackendToClient(ctx, backendConn, clientConn, connectionID, pongTimeout)
}()
// Wait for both directions to complete
wg.Wait()
// Stop the ping goroutine and wait for it to actually exit before this
// handler returns - see the pingDone comment above for why this join is
// required, not optional.
cancelPing()
<-pingDone
duration := time.Since(startTime)
if wsp.logger != nil {
wsp.logger.Info(&libpack_logger.LogMessage{
Message: "WebSocket connection closed",
Pairs: map[string]any{
"connection_id": connectionID,
"duration_seconds": duration.Seconds(),
"messages_sent": wsp.messagesSent.Load(),
"messages_received": wsp.messagesReceived.Load(),
},
})
}
if wsp.monitoring != nil {
wsp.monitoring.Update("graphql_proxy_websocket_connection_duration", nil, duration.Seconds())
}
}
// proxyClientToBackend proxies messages from client to backend. pongTimeout
// is the effective (defaulted+clamped) value computed once by
// handleConnection via effectiveKeepalive; a value of 0 means keepalive is
// disabled and no read deadline is refreshed here.
func (wsp *WebSocketProxy) proxyClientToBackend(ctx context.Context, client *websocket.Conn, backend *gorillaws.Conn, connectionID string, pongTimeout time.Duration) {
for {
select {
case <-ctx.Done():
return
default:
messageType, message, err := client.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
if wsp.logger != nil {
wsp.logger.Debug(&libpack_logger.LogMessage{
Message: "Client WebSocket closed normally",
Pairs: map[string]any{
"connection_id": connectionID,
},
})
}
} else {
wsp.errors.Add(1)
if wsp.logger != nil {
wsp.logger.Error(&libpack_logger.LogMessage{
Message: "Error reading from client WebSocket",
Pairs: map[string]any{
"connection_id": connectionID,
"error": err.Error(),
},
})
}
}
return
}
wsp.messagesSent.Add(1)
// Refresh the read deadline on real client traffic so a client that
// sends data (even without pongs) is never reaped by the keepalive.
if pongTimeout > 0 {
_ = client.SetReadDeadline(time.Now().Add(pongTimeout))
}
// Forward message to backend
if err := backend.WriteMessage(messageType, message); err != nil {
wsp.errors.Add(1)
if wsp.logger != nil {
wsp.logger.Error(&libpack_logger.LogMessage{
Message: "Error writing to backend WebSocket",
Pairs: map[string]any{
"connection_id": connectionID,
"error": err.Error(),
},
})
}
return
}
if wsp.logger != nil {
wsp.logger.Debug(&libpack_logger.LogMessage{
Message: "Message proxied to backend",
Pairs: map[string]any{
"connection_id": connectionID,
"message_type": messageType,
"message_size": len(message),
},
})
}
}
}
}
// proxyBackendToClient proxies messages from backend to client. pongTimeout
// is the effective (defaulted+clamped) value computed once by
// handleConnection via effectiveKeepalive; a value of 0 means keepalive is
// disabled and no read deadline is refreshed here.
func (wsp *WebSocketProxy) proxyBackendToClient(ctx context.Context, backend *gorillaws.Conn, client *websocket.Conn, connectionID string, pongTimeout time.Duration) {
for {
select {
case <-ctx.Done():
return
default:
messageType, message, err := backend.ReadMessage()
if err != nil {
if gorillaws.IsCloseError(err, gorillaws.CloseNormalClosure, gorillaws.CloseGoingAway) {
if wsp.logger != nil {
wsp.logger.Debug(&libpack_logger.LogMessage{
Message: "Backend WebSocket closed normally",
Pairs: map[string]any{
"connection_id": connectionID,
},
})
}
} else {
wsp.errors.Add(1)
if wsp.logger != nil {
wsp.logger.Error(&libpack_logger.LogMessage{
Message: "Error reading from backend WebSocket",
Pairs: map[string]any{
"connection_id": connectionID,
"error": err.Error(),
},
})
}
}
return
}
wsp.messagesReceived.Add(1)
// Refresh the backend read deadline on real traffic too: a backend
// that streams data but does not answer control pings must not be
// reaped by the keepalive.
if pongTimeout > 0 {
_ = backend.SetReadDeadline(time.Now().Add(pongTimeout))
}
// Forward message to client
if err := client.WriteMessage(messageType, message); err != nil {
wsp.errors.Add(1)
if wsp.logger != nil {
wsp.logger.Error(&libpack_logger.LogMessage{
Message: "Error writing to client WebSocket",
Pairs: map[string]any{
"connection_id": connectionID,
"error": err.Error(),
},
})
}
return
}
if wsp.logger != nil {
wsp.logger.Debug(&libpack_logger.LogMessage{
Message: "Message proxied to client",
Pairs: map[string]any{
"connection_id": connectionID,
"message_type": messageType,
"message_size": len(message),
},
})
}
}
}
}
// extractAuthFromPayload extracts authentication headers from GraphQL WebSocket connection_init payload
// This bridges the gap between clients sending auth in payload and Hasura expecting it in HTTP headers
func (wsp *WebSocketProxy) extractAuthFromPayload(message []byte, originalHeaders http.Header) http.Header {
// Create a copy of original headers
enrichedHeaders := make(http.Header)
for k, v := range originalHeaders {
enrichedHeaders[k] = v
}
// Try to parse as JSON to extract headers from payload
var msg map[string]any
if err := json.Unmarshal(message, &msg); err != nil {
// Not JSON or parse error, return original headers
return enrichedHeaders
}
// Check if this is a connection_init message
msgType, ok := msg["type"].(string)
if !ok || (msgType != "connection_init" && msgType != "start") {
// Not a connection_init, return original headers
return enrichedHeaders
}
// Extract payload
payload, ok := msg["payload"].(map[string]any)
if !ok {
return enrichedHeaders
}
// Try to extract headers from payload.headers (graphql-ws format)
if payloadHeaders, ok := payload["headers"].(map[string]any); ok {
for key, value := range payloadHeaders {
if strValue, ok := value.(string); ok {
enrichedHeaders.Set(key, strValue)
}
}
}
// Also check top-level payload keys that look like headers (Apollo format)
for key, value := range payload {
if strValue, ok := value.(string); ok {
// Common auth headers
if key == "Authorization" || key == "authorization" ||
key == "x-hasura-role" || key == "x-hasura-admin-secret" {
enrichedHeaders.Set(key, strValue)
}
}
}
return enrichedHeaders
}
// dialBackend establishes a WebSocket connection to the backend
func (wsp *WebSocketProxy) dialBackend(ctx context.Context, headers http.Header) (*gorillaws.Conn, error) {
// Convert http:// to ws:// or https:// to wss://
wsURL := wsp.backendURL
if len(wsURL) > 7 && wsURL[:7] == "http://" {
wsURL = "ws://" + wsURL[7:]
} else if len(wsURL) > 8 && wsURL[:8] == "https://" {
wsURL = "wss://" + wsURL[8:]
}
// Append GraphQL WebSocket path
wsURL = wsURL + "/v1/graphql"
// Extract subprotocols from headers (e.g., graphql-ws, graphql-transport-ws)
var subprotocols []string
if proto := headers.Get("Sec-WebSocket-Protocol"); proto != "" {
subprotocols = []string{proto}
// Remove from headers since it will be set via Subprotocols field
headers.Del("Sec-WebSocket-Protocol")
}
// Use gorilla websocket dialer
dialer := gorillaws.Dialer{
HandshakeTimeout: 10 * time.Second,
Subprotocols: subprotocols,
}
// Dial the backend with forwarded headers
conn, _, err := dialer.DialContext(ctx, wsURL, headers)
if err != nil {
return nil, fmt.Errorf("failed to dial backend WebSocket: %w", err)
}
return conn, nil
}
// GetStats returns WebSocket statistics. ping_interval/pong_timeout report
// the effective (defaulted-and-clamped) keepalive values actually in use -
// see computeEffectiveKeepalive - not the raw configured fields, so callers
// see what handleConnection really does with idle connections.
func (wsp *WebSocketProxy) GetStats() map[string]any {
return map[string]any{
"enabled": wsp.enabled,
"active_connections": wsp.activeConnections.Load(),
"total_connections": wsp.totalConnections.Load(),
"messages_sent": wsp.messagesSent.Load(),
"messages_received": wsp.messagesReceived.Load(),
"errors": wsp.errors.Load(),
"ping_interval": wsp.effectivePingInterval.String(),
"pong_timeout": wsp.effectivePongTimeout.String(),
"max_message_size": wsp.maxMessageSize,
}
}
// IsWebSocketRequest checks if the request is a WebSocket upgrade request
func IsWebSocketRequest(c fiber.Ctx) bool {
return websocket.IsWebSocketUpgrade(c) ||
c.Get("Upgrade") == "websocket" ||
c.Get("Connection") == "Upgrade"
}
// Global WebSocket proxy
var (
webSocketProxy *WebSocketProxy
webSocketProxyOnce sync.Once
)
// InitializeWebSocketProxy initializes the global WebSocket proxy
func InitializeWebSocketProxy(backendURL string, config WebSocketConfig, logger *libpack_logger.Logger, monitoring *libpack_monitoring.MetricsSetup) *WebSocketProxy {
webSocketProxyOnce.Do(func() {
webSocketProxy = NewWebSocketProxy(backendURL, config, logger, monitoring)
})
return webSocketProxy
}
// GetWebSocketProxy returns the global WebSocket proxy
func GetWebSocketProxy() *WebSocketProxy {
return webSocketProxy
}