snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
34
internal/uniws/cancelctx.go
Normal file
34
internal/uniws/cancelctx.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package uniws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// customCancelContext wraps context and cancels as soon as channel closed.
|
||||
type customCancelContext struct {
|
||||
context.Context
|
||||
ch <-chan struct{}
|
||||
}
|
||||
|
||||
// Deadline not used.
|
||||
func (c customCancelContext) Deadline() (time.Time, bool) { return time.Time{}, false }
|
||||
|
||||
// Done returns channel that will be closed as soon as connection closed.
|
||||
func (c customCancelContext) Done() <-chan struct{} { return c.ch }
|
||||
|
||||
// Err returns context error.
|
||||
func (c customCancelContext) Err() error {
|
||||
select {
|
||||
case <-c.ch:
|
||||
return context.Canceled
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewCancelContext returns a wrapper context around original context that will
|
||||
// be canceled on channel close.
|
||||
func NewCancelContext(ctx context.Context, ch <-chan struct{}) context.Context {
|
||||
return customCancelContext{Context: ctx, ch: ch}
|
||||
}
|
||||
53
internal/uniws/config.go
Normal file
53
internal/uniws/config.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package uniws
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
)
|
||||
|
||||
// Defaults.
|
||||
const (
|
||||
DefaultWebsocketPingInterval = 25 * time.Second
|
||||
DefaultWebsocketPongTimeout = 10 * time.Second
|
||||
DefaultWebsocketWriteTimeout = 1 * time.Second
|
||||
DefaultWebsocketMessageSizeLimit = 65536 // 64KB
|
||||
)
|
||||
|
||||
// DefaultWebsocketDecompressedMessageSizeLimitMultiplier is the default factor
|
||||
// applied to MessageSizeLimit to derive the maximum allowed decompressed message
|
||||
// size when compression is negotiated and DecompressedMessageSizeLimit is not set
|
||||
// explicitly. MessageSizeLimit alone only bounds the compressed bytes received on
|
||||
// the wire, so a small compressed frame could otherwise be inflated into a much
|
||||
// larger amount of memory (a "decompression bomb"). The multiplier leaves
|
||||
// generous headroom for legitimately compressible messages while still rejecting
|
||||
// extreme expansion ratios.
|
||||
const DefaultWebsocketDecompressedMessageSizeLimitMultiplier = 10
|
||||
|
||||
type Config = configtypes.UniWebSocket
|
||||
|
||||
func sameHostOriginCheck() func(r *http.Request) bool {
|
||||
return func(r *http.Request) bool {
|
||||
err := checkSameHost(r)
|
||||
return err == nil
|
||||
}
|
||||
}
|
||||
|
||||
func checkSameHost(r *http.Request) error {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse Origin header %q: %w", origin, err)
|
||||
}
|
||||
if strings.EqualFold(r.Host, u.Host) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("request Origin %q is not authorized for Host %q", origin, r.Host)
|
||||
}
|
||||
239
internal/uniws/handler.go
Normal file
239
internal/uniws/handler.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package uniws
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/convert"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/logging"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/websocket"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
"github.com/centrifugal/protocol"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
|
||||
// Handler handles WebSocket client connections. Usually WebSocket protocol
|
||||
// is a bidirectional connection between a client and a server for low-latency
|
||||
// communication. Here we utilize only one direction - giving users an additional
|
||||
// option for unidirectional transport.
|
||||
type Handler struct {
|
||||
node *centrifuge.Node
|
||||
upgrade *websocket.Upgrader
|
||||
config Config
|
||||
pingPong centrifuge.PingPongConfig
|
||||
}
|
||||
|
||||
// Support passing connect request in URL params.
|
||||
// The value should be a properly encoded JSON object representing protocol.ConnectRequest.
|
||||
const connectUrlParam = "cf_connect"
|
||||
|
||||
var writeBufferPool = &sync.Pool{}
|
||||
|
||||
// NewHandler creates new Handler.
|
||||
func NewHandler(
|
||||
n *centrifuge.Node, c Config, CheckOrigin func(r *http.Request) bool, pingPong centrifuge.PingPongConfig,
|
||||
) *Handler {
|
||||
upgrade := &websocket.Upgrader{
|
||||
ReadBufferSize: c.ReadBufferSize,
|
||||
EnableCompression: c.Compression,
|
||||
}
|
||||
if c.UseWriteBufferPool {
|
||||
upgrade.WriteBufferPool = writeBufferPool
|
||||
} else {
|
||||
upgrade.WriteBufferSize = c.WriteBufferSize
|
||||
}
|
||||
if CheckOrigin != nil {
|
||||
upgrade.CheckOrigin = CheckOrigin
|
||||
} else {
|
||||
upgrade.CheckOrigin = sameHostOriginCheck()
|
||||
}
|
||||
return &Handler{
|
||||
node: n,
|
||||
config: c,
|
||||
upgrade: upgrade,
|
||||
pingPong: pingPong,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
compression := s.config.Compression
|
||||
compressionLevel := s.config.CompressionLevel
|
||||
compressionMinSize := s.config.CompressionMinSize
|
||||
|
||||
var req *protocol.ConnectRequest
|
||||
connectRequestString := r.URL.Query().Get(connectUrlParam)
|
||||
if connectRequestString != "" {
|
||||
_, err := json.Parse(convert.StringToBytes(connectRequestString), &req, json.ZeroCopy)
|
||||
if err != nil {
|
||||
log.Info().Err(err).Str("transport", transportName).Msg("error unmarshalling connect request")
|
||||
http.Error(rw, "invalid connect request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
conn, _, err := s.upgrade.Upgrade(rw, r, nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("transport", transportName).Msg("websocket upgrade error")
|
||||
return
|
||||
}
|
||||
|
||||
if compression {
|
||||
err := conn.SetCompressionLevel(compressionLevel)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("websocket error setting compression level")
|
||||
}
|
||||
}
|
||||
|
||||
framePingInterval := s.pingPong.PingInterval
|
||||
if framePingInterval <= 0 {
|
||||
// Always use frame ping/pong in unidirectional WebSocket transport.
|
||||
framePingInterval = DefaultWebsocketPingInterval
|
||||
}
|
||||
framePongTimeout := s.pingPong.PongTimeout
|
||||
if framePongTimeout <= 0 {
|
||||
framePongTimeout = DefaultWebsocketPongTimeout
|
||||
}
|
||||
writeTimeout := s.config.WriteTimeout.ToDuration()
|
||||
if writeTimeout == 0 {
|
||||
writeTimeout = DefaultWebsocketWriteTimeout
|
||||
}
|
||||
messageSizeLimit := s.config.MessageSizeLimit
|
||||
if messageSizeLimit == 0 {
|
||||
messageSizeLimit = DefaultWebsocketMessageSizeLimit
|
||||
}
|
||||
|
||||
if messageSizeLimit > 0 {
|
||||
conn.SetReadLimit(int64(messageSizeLimit))
|
||||
}
|
||||
if compression {
|
||||
decompressedMessageSizeLimit := s.config.DecompressedMessageSizeLimit
|
||||
if decompressedMessageSizeLimit == 0 {
|
||||
decompressedMessageSizeLimit = messageSizeLimit * DefaultWebsocketDecompressedMessageSizeLimitMultiplier
|
||||
}
|
||||
if decompressedMessageSizeLimit > 0 {
|
||||
conn.SetDecompressedReadLimit(int64(decompressedMessageSizeLimit))
|
||||
}
|
||||
}
|
||||
|
||||
// Separate goroutine for better GC of caller's data.
|
||||
go func() {
|
||||
// Record server-sent (outgoing) close codes for observability. Runs last
|
||||
// (registered first), after any teardown close frame has been written.
|
||||
// Client-supplied codes are skipped to keep metric cardinality bounded.
|
||||
defer func() {
|
||||
if code, incoming := conn.CloseCode(); code != 0 && !incoming {
|
||||
s.node.IncTransportOutgoingClose(transportName, code)
|
||||
}
|
||||
}()
|
||||
opts := websocketTransportOptions{
|
||||
framePingInterval: framePingInterval,
|
||||
framePongTimeout: framePongTimeout,
|
||||
writeTimeout: writeTimeout,
|
||||
compressionMinSize: compressionMinSize,
|
||||
pingPongConfig: s.pingPong,
|
||||
joinMessages: s.config.JoinPushMessages,
|
||||
protoMajor: r.ProtoMajor,
|
||||
disableClosingHandshake: s.config.DisableClosingHandshake,
|
||||
disableDisconnectPush: s.config.DisableDisconnectPush,
|
||||
}
|
||||
|
||||
graceCh := make(chan struct{})
|
||||
transport := newWebsocketTransport(conn, opts, graceCh)
|
||||
|
||||
select {
|
||||
case <-s.node.NotifyShutdown():
|
||||
_ = transport.Close(centrifuge.DisconnectShutdown)
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
ctxCh := make(chan struct{})
|
||||
defer close(ctxCh)
|
||||
|
||||
c, closeFn, err := centrifuge.NewClient(NewCancelContext(r.Context(), ctxCh), s.node, transport)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("transport", transportName).Msg("error creating client")
|
||||
return
|
||||
}
|
||||
defer func() { _ = closeFn() }()
|
||||
|
||||
if logging.Enabled(logging.DebugLevel) {
|
||||
log.Debug().Str("transport", transportName).Str("client", c.ID()).Msg("client connection established")
|
||||
defer func(started time.Time) {
|
||||
log.Debug().Str("transport", transportName).Str("client", c.ID()).
|
||||
Str("duration", time.Since(started).String()).Msg("client connection completed")
|
||||
}(time.Now())
|
||||
}
|
||||
|
||||
pongWait := framePingInterval + framePongTimeout
|
||||
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetPongHandler(func([]byte) error {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
waitClose := func() {
|
||||
// https://github.com/gorilla/websocket/issues/448
|
||||
conn.SetPingHandler(nil)
|
||||
conn.SetPongHandler(nil)
|
||||
if s.config.DisableClosingHandshake {
|
||||
close(graceCh)
|
||||
return
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(closeFrameWait))
|
||||
for {
|
||||
if _, _, err := conn.NextReader(); err != nil {
|
||||
close(graceCh)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req == nil {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
waitClose()
|
||||
return
|
||||
}
|
||||
_, err = json.Parse(data, &req, json.ZeroCopy)
|
||||
if err != nil {
|
||||
log.Info().Err(err).Str("transport", transportName).Msg("error unmarshalling connect request")
|
||||
if !s.config.DisableClosingHandshake {
|
||||
err = conn.WriteControl(
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(
|
||||
int(centrifuge.DisconnectBadRequest.Code),
|
||||
centrifuge.DisconnectBadRequest.Reason,
|
||||
),
|
||||
time.Now().Add(writeTimeout))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
waitClose()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req == nil {
|
||||
// A literal `null` connect frame decodes to a nil request with no error;
|
||||
// treat it as an empty connect request. Passing nil to the connect
|
||||
// handler nil-derefs, and this runs in a bare goroutine (ServeHTTP has
|
||||
// already returned), so the panic would crash the whole process.
|
||||
req = &protocol.ConnectRequest{}
|
||||
}
|
||||
|
||||
c.ProtocolConnect(req)
|
||||
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
waitClose()
|
||||
}()
|
||||
}
|
||||
323
internal/uniws/handler_test.go
Normal file
323
internal/uniws/handler_test.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package uniws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/middleware"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/websocket"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
"github.com/centrifugal/protocol"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func ensureMessageHasClient(t *testing.T, data []byte) {
|
||||
t.Log(string(data))
|
||||
var reply protocol.Reply
|
||||
err := json.Unmarshal(data, &reply)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, reply.Connect.Client)
|
||||
}
|
||||
|
||||
func TestUnidirectionalWebSocket(t *testing.T) {
|
||||
t.Parallel()
|
||||
node, err := centrifuge.New(centrifuge.Config{
|
||||
ClientStaleCloseDelay: time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = node.Shutdown(context.Background()) })
|
||||
|
||||
node.OnConnecting(func(ctx context.Context, event centrifuge.ConnectEvent) (centrifuge.ConnectReply, error) {
|
||||
return centrifuge.ConnectReply{
|
||||
Credentials: ¢rifuge.Credentials{},
|
||||
}, nil
|
||||
})
|
||||
|
||||
config := configtypes.UniWebSocket{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
WriteTimeout: configtypes.Duration(time.Second),
|
||||
MessageSizeLimit: 65536,
|
||||
}
|
||||
|
||||
pingPong := centrifuge.PingPongConfig{
|
||||
PingInterval: 5 * time.Second,
|
||||
PongTimeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
handler := NewHandler(node, config, func(r *http.Request) bool {
|
||||
t.Logf("Checking origin: %s", r.Header.Get("Origin"))
|
||||
return r.Header.Get("Origin") == "" || r.Header.Get("Origin") == "https://example.com"
|
||||
}, pingPong)
|
||||
|
||||
server := httptest.NewServer(middleware.LogRequest(handler))
|
||||
t.Cleanup(func() { server.Close() })
|
||||
|
||||
waitServer := func() {
|
||||
t.Helper()
|
||||
// Wait for server to start.
|
||||
for {
|
||||
t.Logf("Waiting for server to start: %s", server.URL)
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
waitServer()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
|
||||
t.Run("successful upgrade", func(t *testing.T) {
|
||||
dialer := websocket.Dialer{}
|
||||
conn, _, _, err := dialer.Dial(wsURL, http.Header{"Origin": []string{"https://example.com"}})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
connectMsg := `{}`
|
||||
err = conn.WriteMessage(websocket.TextMessage, []byte(connectMsg))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, data, err := conn.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
ensureMessageHasClient(t, data)
|
||||
})
|
||||
|
||||
t.Run("invalid connect request", func(t *testing.T) {
|
||||
dialer := websocket.Dialer{}
|
||||
conn, _, _, err := dialer.Dial(wsURL, http.Header{"Origin": []string{"https://example.com"}})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
connectMsg := `invalid`
|
||||
err = conn.WriteMessage(websocket.TextMessage, []byte(connectMsg))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = conn.ReadMessage()
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("bad origin", func(t *testing.T) {
|
||||
dialer := websocket.Dialer{}
|
||||
_, _, _, err := dialer.Dial(wsURL, http.Header{"Origin": []string{"https://evil.com"}})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "bad handshake", err.Error())
|
||||
})
|
||||
|
||||
t.Run("connect request in URL params", func(t *testing.T) {
|
||||
connectReq := `{}`
|
||||
params := url.Values{}
|
||||
params.Set(connectUrlParam, connectReq)
|
||||
|
||||
dialer := websocket.Dialer{}
|
||||
conn, _, _, err := dialer.Dial(wsURL+"?"+params.Encode(), nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
_, data, err := conn.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
ensureMessageHasClient(t, data)
|
||||
})
|
||||
|
||||
t.Run("invalid connect request in URL params", func(t *testing.T) {
|
||||
params := url.Values{}
|
||||
params.Set(connectUrlParam, "invalid-json")
|
||||
|
||||
dialer := websocket.Dialer{}
|
||||
_, _, _, err := dialer.Dial(wsURL+"?"+params.Encode(), nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "bad handshake", err.Error())
|
||||
})
|
||||
|
||||
t.Run("connect request timeout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: 1 * time.Second,
|
||||
}
|
||||
conn, _, _, err := dialer.Dial(wsURL, nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
started := time.Now()
|
||||
err = conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
require.NoError(t, err)
|
||||
_, _, err = conn.ReadMessage()
|
||||
require.NoError(t, err) // Disconnect stale message.
|
||||
_, _, err = conn.ReadMessage()
|
||||
require.Error(t, err)
|
||||
require.WithinDuration(t, started, time.Now(), 3*time.Second)
|
||||
})
|
||||
|
||||
t.Run("ping pong cycle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dialer := websocket.Dialer{}
|
||||
conn, _, _, err := dialer.Dial(wsURL, nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
err = conn.WriteMessage(websocket.TextMessage, []byte(`{}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
pingReceived := false
|
||||
conn.SetPingHandler(func(appData []byte) error {
|
||||
pingReceived = true
|
||||
t.Log("websocket frame ping received")
|
||||
return conn.WriteMessage(websocket.PongMessage, appData)
|
||||
})
|
||||
|
||||
err = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
require.NoError(t, err)
|
||||
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if string(data) == `{}` {
|
||||
t.Logf("centrifugal protocol ping received: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
require.True(t, pingReceived, "Expected to receive ping message")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnidirectionalWebSocket_DecompressionBombRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node, err := centrifuge.New(centrifuge.Config{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = node.Shutdown(context.Background()) })
|
||||
|
||||
node.OnConnecting(func(ctx context.Context, event centrifuge.ConnectEvent) (centrifuge.ConnectReply, error) {
|
||||
return centrifuge.ConnectReply{Credentials: ¢rifuge.Credentials{}}, nil
|
||||
})
|
||||
|
||||
// MessageSizeLimit 1024 -> decompressed limit = 1024 * 10 = 10240 bytes.
|
||||
config := configtypes.UniWebSocket{
|
||||
Compression: true,
|
||||
MessageSizeLimit: 1024,
|
||||
}
|
||||
|
||||
handler := NewHandler(node, config, func(r *http.Request) bool { return true }, centrifuge.PingPongConfig{})
|
||||
server := httptest.NewServer(middleware.LogRequest(handler))
|
||||
t.Cleanup(func() { server.Close() })
|
||||
|
||||
for {
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
break
|
||||
}
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
|
||||
dialer := websocket.Dialer{EnableCompression: true}
|
||||
conn, _, _, err := dialer.Dial(wsURL, nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = conn.Close() }()
|
||||
require.NoError(t, conn.SetCompressionLevel(9))
|
||||
|
||||
// 64KB of zeros compresses to well under the 1024-byte compressed limit but
|
||||
// inflates far past the 10240-byte decompressed limit, as the first
|
||||
// (pre-auth) frame.
|
||||
bomb := make([]byte, 64*1024)
|
||||
require.NoError(t, conn.WriteMessage(websocket.BinaryMessage, bomb))
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
_, _, err = conn.ReadMessage()
|
||||
require.Error(t, err)
|
||||
require.Truef(t, websocket.IsCloseError(err, websocket.CloseMessageTooBig),
|
||||
"expected close %d, got %v", websocket.CloseMessageTooBig, err)
|
||||
}
|
||||
|
||||
func TestUnidirectionalWebSocket_CloseFrameSent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a custom node to trigger server-side disconnect.
|
||||
testNode, err := centrifuge.New(centrifuge.Config{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = testNode.Shutdown(context.Background()) })
|
||||
|
||||
testNode.OnConnecting(func(ctx context.Context, event centrifuge.ConnectEvent) (centrifuge.ConnectReply, error) {
|
||||
return centrifuge.ConnectReply{
|
||||
Credentials: ¢rifuge.Credentials{},
|
||||
}, nil
|
||||
})
|
||||
|
||||
testNode.OnConnect(func(client *centrifuge.Client) {
|
||||
client.Disconnect(centrifuge.DisconnectConnectionLimit)
|
||||
})
|
||||
|
||||
testHandler := NewHandler(testNode, Config{}, func(r *http.Request) bool {
|
||||
return true
|
||||
}, centrifuge.PingPongConfig{})
|
||||
|
||||
testServer := httptest.NewServer(middleware.LogRequest(testHandler))
|
||||
t.Cleanup(func() { testServer.Close() })
|
||||
|
||||
testWsURL := "ws" + strings.TrimPrefix(testServer.URL, "http")
|
||||
|
||||
// Wait for test server to start.
|
||||
for {
|
||||
resp, err := http.Get(testServer.URL)
|
||||
if err != nil {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
break
|
||||
}
|
||||
|
||||
dialer := websocket.Dialer{}
|
||||
conn, _, _, err := dialer.Dial(testWsURL, nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
// Send connect request.
|
||||
err = conn.WriteMessage(websocket.TextMessage, []byte(`{}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read connect reply.
|
||||
_, data, err := conn.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
ensureMessageHasClient(t, data)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(10*time.Second)))
|
||||
|
||||
// Read until connection is closed - server should send close frame.
|
||||
var closeErr *websocket.CloseError
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Logf("ReadMessage error: %v", err)
|
||||
closeErr = &websocket.CloseError{}
|
||||
if ok := errors.As(err, &closeErr); ok {
|
||||
t.Logf("Close frame received: code=%d, text=%s", closeErr.Code, closeErr.Text)
|
||||
break
|
||||
}
|
||||
// Some other error occurred.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Verify close frame was received with correct code and reason.
|
||||
require.NotNil(t, closeErr, "Expected to receive close frame")
|
||||
require.Equal(t, centrifuge.DisconnectConnectionLimit.Code, uint32(closeErr.Code))
|
||||
require.Equal(t, centrifuge.DisconnectConnectionLimit.Reason, closeErr.Text)
|
||||
}
|
||||
266
internal/uniws/transport.go
Normal file
266
internal/uniws/transport.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package uniws
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/timers"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/tools"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/websocket"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
"github.com/centrifugal/protocol"
|
||||
)
|
||||
|
||||
// websocketTransport is a wrapper struct over websocket connection to fit session
|
||||
// interface so client will accept it.
|
||||
type websocketTransport struct {
|
||||
mu sync.RWMutex
|
||||
writeMu sync.Mutex // sync general write with unidirectional ping write.
|
||||
conn *websocket.Conn
|
||||
closeCh chan struct{}
|
||||
graceCh chan struct{}
|
||||
opts websocketTransportOptions
|
||||
pingTimer *time.Timer
|
||||
closed bool
|
||||
}
|
||||
|
||||
type websocketTransportOptions struct {
|
||||
framePingInterval time.Duration
|
||||
framePongTimeout time.Duration
|
||||
writeTimeout time.Duration
|
||||
compressionMinSize int
|
||||
pingPongConfig centrifuge.PingPongConfig
|
||||
protoMajor int
|
||||
joinMessages bool
|
||||
disableClosingHandshake bool // TODO: consider keeping/removing for Centrifugo v7.
|
||||
disableDisconnectPush bool
|
||||
}
|
||||
|
||||
func newWebsocketTransport(conn *websocket.Conn, opts websocketTransportOptions, graceCh chan struct{}) *websocketTransport {
|
||||
transport := &websocketTransport{
|
||||
conn: conn,
|
||||
closeCh: make(chan struct{}),
|
||||
graceCh: graceCh,
|
||||
opts: opts,
|
||||
}
|
||||
if opts.framePingInterval > 0 {
|
||||
transport.addPing()
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
func (t *websocketTransport) ping() {
|
||||
select {
|
||||
case <-t.closeCh:
|
||||
return
|
||||
default:
|
||||
if t.opts.framePongTimeout > 0 {
|
||||
// It's safe to call SetReadDeadline concurrently with reader in separate goroutine.
|
||||
// According to Go docs:
|
||||
// SetReadDeadline sets the deadline for future Read calls and any currently-blocked Read call.
|
||||
_ = t.conn.SetReadDeadline(time.Now().Add(t.opts.framePongTimeout))
|
||||
}
|
||||
err := t.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(t.opts.writeTimeout))
|
||||
if err != nil {
|
||||
_ = t.Close(centrifuge.DisconnectWriteError)
|
||||
return
|
||||
}
|
||||
if t.opts.writeTimeout > 0 && t.opts.protoMajor > 1 {
|
||||
// WriteControl set a write deadline on the underlying HTTP/2 stream but,
|
||||
// unlike writeData, nothing clears it here. An expired HTTP/2 deadline
|
||||
// cannot be extended and permanently RSTs the stream, so an idle
|
||||
// connection would be dropped ~writeTimeout after each ping. Clear it,
|
||||
// serialized against writeData via writeMu.
|
||||
t.writeMu.Lock()
|
||||
derr := t.conn.NetConn().SetWriteDeadline(time.Time{})
|
||||
t.writeMu.Unlock()
|
||||
if derr != nil {
|
||||
_ = t.Close(centrifuge.DisconnectWriteError)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.addPing()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *websocketTransport) addPing() {
|
||||
t.mu.Lock()
|
||||
if t.closed {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
t.pingTimer = time.AfterFunc(t.opts.framePingInterval, t.ping)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
const transportName = "uni_websocket"
|
||||
|
||||
// Name returns name of transport.
|
||||
func (t *websocketTransport) Name() string {
|
||||
return transportName
|
||||
}
|
||||
|
||||
// Protocol returns transport protocol.
|
||||
func (t *websocketTransport) Protocol() centrifuge.ProtocolType {
|
||||
return centrifuge.ProtocolTypeJSON
|
||||
}
|
||||
|
||||
// ProtocolVersion returns transport protocol version.
|
||||
func (t *websocketTransport) ProtocolVersion() centrifuge.ProtocolVersion {
|
||||
return centrifuge.ProtocolVersion2
|
||||
}
|
||||
|
||||
// Unidirectional returns whether transport is unidirectional.
|
||||
func (t *websocketTransport) Unidirectional() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// DisabledPushFlags ...
|
||||
func (t *websocketTransport) DisabledPushFlags() uint64 {
|
||||
if t.opts.disableDisconnectPush {
|
||||
return centrifuge.PushFlagDisconnect
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// PingPongConfig ...
|
||||
func (t *websocketTransport) PingPongConfig() centrifuge.PingPongConfig {
|
||||
return t.opts.pingPongConfig
|
||||
}
|
||||
|
||||
// Emulation ...
|
||||
func (t *websocketTransport) Emulation() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// AcceptProtocol ...
|
||||
func (t *websocketTransport) AcceptProtocol() string {
|
||||
return tools.GetAcceptProtocolLabel(t.opts.protoMajor)
|
||||
}
|
||||
|
||||
func (t *websocketTransport) writeData(data []byte) error {
|
||||
if t.opts.compressionMinSize > 0 {
|
||||
t.conn.EnableWriteCompression(len(data) > t.opts.compressionMinSize)
|
||||
}
|
||||
var messageType = websocket.TextMessage
|
||||
if t.Protocol() == centrifuge.ProtocolTypeProtobuf {
|
||||
messageType = websocket.BinaryMessage
|
||||
}
|
||||
|
||||
t.writeMu.Lock()
|
||||
if t.opts.writeTimeout > 0 {
|
||||
_ = t.conn.SetWriteDeadline(time.Now().Add(t.opts.writeTimeout))
|
||||
}
|
||||
err := t.conn.WriteMessage(messageType, data)
|
||||
if err != nil {
|
||||
t.writeMu.Unlock()
|
||||
return err
|
||||
}
|
||||
if t.opts.writeTimeout > 0 {
|
||||
_ = t.conn.SetWriteDeadline(time.Time{})
|
||||
if t.opts.protoMajor > 1 {
|
||||
// For HTTP/2 connections, we need to actually clear the deadline on the underlying
|
||||
// connection. The websocket Conn.SetWriteDeadline only sets a field, but doesn't
|
||||
// clear the deadline that was already set on the underlying net.Conn during write.
|
||||
// This is critical for HTTP/2 ResponseController where expired deadlines cannot be
|
||||
// extended and will cause the stream to fail permanently.
|
||||
err = t.conn.NetConn().SetWriteDeadline(time.Time{})
|
||||
if err != nil {
|
||||
t.writeMu.Unlock()
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
t.writeMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *websocketTransport) writeMany(messages ...[]byte) error {
|
||||
protoType := protocol.TypeJSON
|
||||
if t.Protocol() == centrifuge.ProtocolTypeProtobuf {
|
||||
protoType = protocol.TypeProtobuf
|
||||
}
|
||||
encoder := protocol.GetDataEncoder(protoType)
|
||||
defer protocol.PutDataEncoder(protoType, encoder)
|
||||
for i := range messages {
|
||||
_ = encoder.Encode(messages[i])
|
||||
}
|
||||
return t.writeData(encoder.Finish())
|
||||
}
|
||||
|
||||
// Write data to transport.
|
||||
func (t *websocketTransport) Write(message []byte) error {
|
||||
return t.WriteMany(message)
|
||||
}
|
||||
|
||||
// WriteMany data to transport.
|
||||
func (t *websocketTransport) WriteMany(messages ...[]byte) error {
|
||||
select {
|
||||
case <-t.closeCh:
|
||||
return nil
|
||||
default:
|
||||
var err error
|
||||
if t.opts.joinMessages {
|
||||
if len(messages) == 1 && t.Protocol() == centrifuge.ProtocolTypeJSON {
|
||||
// Fast path for one JSON message.
|
||||
return t.writeData(messages[0])
|
||||
}
|
||||
err = t.writeMany(messages...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
for i := 0; i < len(messages); i++ {
|
||||
err = t.writeData(messages[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
const closeFrameWait = 5 * time.Second
|
||||
|
||||
// Close closes transport.
|
||||
func (t *websocketTransport) Close(disconnect centrifuge.Disconnect) error {
|
||||
t.mu.Lock()
|
||||
if t.closed {
|
||||
t.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
t.closed = true
|
||||
if t.pingTimer != nil {
|
||||
t.pingTimer.Stop()
|
||||
}
|
||||
close(t.closeCh)
|
||||
t.mu.Unlock()
|
||||
|
||||
if t.opts.disableClosingHandshake {
|
||||
return t.conn.Close()
|
||||
}
|
||||
|
||||
if disconnect.Code != centrifuge.DisconnectConnectionClosed.Code {
|
||||
msg := websocket.FormatCloseMessage(int(disconnect.Code), disconnect.Reason)
|
||||
err := t.conn.WriteControl(websocket.CloseMessage, msg, time.Now().Add(t.opts.writeTimeout))
|
||||
if err != nil {
|
||||
return t.conn.Close()
|
||||
}
|
||||
select {
|
||||
case <-t.graceCh:
|
||||
default:
|
||||
// Wait for closing handshake completion.
|
||||
tm := timers.AcquireTimer(closeFrameWait)
|
||||
select {
|
||||
case <-t.graceCh:
|
||||
case <-tm.C:
|
||||
}
|
||||
timers.ReleaseTimer(tm)
|
||||
}
|
||||
return t.conn.Close()
|
||||
}
|
||||
return t.conn.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user