snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:17:21 +05:00
commit b352a9a30d
472 changed files with 108375 additions and 0 deletions

16
internal/proxy/client.go Normal file
View File

@@ -0,0 +1,16 @@
package proxy
import (
"context"
"github.com/centrifugal/centrifuge"
)
type Client interface {
ID() string
UserID() string
Context() context.Context
Transport() centrifuge.TransportInfo
Unsubscribe(ch string, unsubscribe ...centrifuge.Unsubscribe)
WritePublication(channel string, publication *centrifuge.Publication, sp centrifuge.StreamPosition) error
}

19
internal/proxy/connect.go Normal file
View File

@@ -0,0 +1,19 @@
package proxy
import (
"context"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// ConnectProxy allows to proxy connect requests to application backend to
// authenticate client connection.
type ConnectProxy interface {
ProxyConnect(context.Context, *proxyproto.ConnectRequest) (*proxyproto.ConnectResponse, error)
// Name returns proxy name.
Name() string
// Protocol for metrics and logging.
Protocol() string
// UseBase64 for bytes in requests from Centrifugo to application backend.
UseBase64() bool
}

View File

@@ -0,0 +1,59 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"google.golang.org/grpc"
)
// GRPCConnectProxy ...
type GRPCConnectProxy struct {
config Config
client proxyproto.CentrifugoProxyClient
}
var _ ConnectProxy = (*GRPCConnectProxy)(nil)
// NewGRPCConnectProxy ...
func NewGRPCConnectProxy(name string, p Config) (*GRPCConnectProxy, error) {
host, err := getGrpcHost(p.Endpoint)
if err != nil {
return nil, fmt.Errorf("error getting grpc host: %v", err)
}
dialOpts, err := getDialOpts(name, p)
if err != nil {
return nil, fmt.Errorf("error creating GRPC dial options: %v", err)
}
conn, err := grpc.NewClient(host, dialOpts...)
if err != nil {
return nil, fmt.Errorf("error connecting to GRPC proxy server: %v", err)
}
return &GRPCConnectProxy{
config: p,
client: proxyproto.NewCentrifugoProxyClient(conn),
}, nil
}
// Protocol ...
func (p *GRPCConnectProxy) Protocol() string {
return "grpc"
}
func (p *GRPCConnectProxy) Name() string {
return "default"
}
// UseBase64 ...
func (p *GRPCConnectProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// ProxyConnect proxies connect control to application backend.
func (p *GRPCConnectProxy) ProxyConnect(ctx context.Context, req *proxyproto.ConnectRequest) (*proxyproto.ConnectResponse, error) {
ctx, cancel := context.WithTimeout(ctx, p.config.Timeout.ToDuration())
defer cancel()
return p.client.Connect(grpcRequestContext(ctx, p.config), req)
}

View File

@@ -0,0 +1,243 @@
package proxy
import (
"context"
"encoding/base64"
"encoding/json"
"time"
"github.com/centrifugal/centrifugo/v6/internal/clientstorage"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"github.com/centrifugal/centrifugo/v6/internal/subsource"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
"github.com/rs/zerolog/log"
)
// ConnectHandlerConfig ...
type ConnectHandlerConfig struct {
Proxy ConnectProxy
}
// ConnectHandler ...
type ConnectHandler struct {
config ConnectHandlerConfig
cfgContainer *config.Container
summary prometheus.Observer
histogram prometheus.Observer
errors prometheus.Counter
inflight prometheus.Gauge
}
// NewConnectHandler ...
func NewConnectHandler(c ConnectHandlerConfig, cfgContainer *config.Container) *ConnectHandler {
return &ConnectHandler{
config: c,
cfgContainer: cfgContainer,
summary: metrics.ProxyCallDurationSummary.WithLabelValues(c.Proxy.Protocol(), "connect", "default"),
histogram: metrics.ProxyCallDurationHistogram.WithLabelValues(c.Proxy.Protocol(), "connect", "default"),
errors: metrics.ProxyCallErrorCount.WithLabelValues(c.Proxy.Protocol(), "connect", "default"),
inflight: metrics.ProxyCallInflightRequests.WithLabelValues(c.Proxy.Protocol(), "connect", "default"),
}
}
type ConnectExtra struct {
}
type ConnectingHandlerFunc func(context.Context, centrifuge.ConnectEvent) (centrifuge.ConnectReply, ConnectExtra, error)
// Handle returns connecting handler func.
func (h *ConnectHandler) Handle() ConnectingHandlerFunc {
return func(ctx context.Context, e centrifuge.ConnectEvent) (centrifuge.ConnectReply, ConnectExtra, error) {
started := time.Now()
h.inflight.Inc()
defer h.inflight.Dec()
req := &proxyproto.ConnectRequest{
Client: e.ClientID,
Protocol: string(e.Transport.Protocol()),
Transport: e.Transport.Name(),
Encoding: getEncoding(h.config.Proxy.UseBase64()),
Name: e.Name,
Version: e.Version,
Channels: e.Channels,
}
if !h.config.Proxy.UseBase64() {
req.Data = e.Data
} else {
req.B64Data = base64.StdEncoding.EncodeToString(e.Data)
}
connectRep, err := h.config.Proxy.ProxyConnect(ctx, req)
duration := time.Since(started).Seconds()
if err != nil {
select {
case <-ctx.Done():
// Client connection already closed.
return centrifuge.ConnectReply{}, ConnectExtra{}, centrifuge.DisconnectConnectionClosed
default:
}
h.summary.Observe(duration)
h.histogram.Observe(duration)
h.errors.Inc()
log.Error().Err(err).Str("client", e.ClientID).Msg("error proxying connect")
return centrifuge.ConnectReply{}, ConnectExtra{}, err
}
h.summary.Observe(duration)
h.histogram.Observe(duration)
if connectRep.Disconnect != nil {
return centrifuge.ConnectReply{}, ConnectExtra{}, proxyproto.DisconnectFromProto(connectRep.Disconnect)
}
if connectRep.Error != nil {
return centrifuge.ConnectReply{}, ConnectExtra{}, proxyproto.ErrorFromProto(connectRep.Error)
}
result := connectRep.Result
if result == nil {
return centrifuge.ConnectReply{Credentials: nil}, ConnectExtra{}, nil
}
var info []byte
if result.B64Info != "" {
decodedInfo, err := base64.StdEncoding.DecodeString(result.B64Info)
if err != nil {
log.Error().Err(err).Str("client", e.ClientID).Msg("error decoding base64 info")
return centrifuge.ConnectReply{}, ConnectExtra{}, centrifuge.ErrorInternal
}
info = decodedInfo
} else {
info = result.Info
}
var data []byte
if result.B64Data != "" {
decodedData, err := base64.StdEncoding.DecodeString(result.B64Data)
if err != nil {
log.Error().Err(err).Str("client", e.ClientID).Msg("error decoding base64 data")
return centrifuge.ConnectReply{}, ConnectExtra{}, centrifuge.ErrorInternal
}
data = decodedData
} else {
data = result.Data
}
reply := centrifuge.ConnectReply{
Credentials: &centrifuge.Credentials{
UserID: result.User,
ExpireAt: result.ExpireAt,
Info: info,
},
}
if len(data) > 0 {
reply.Data = data
}
if len(result.Channels) > 0 {
if reply.Subscriptions == nil {
reply.Subscriptions = make(map[string]centrifuge.SubscribeOptions, len(result.Channels))
}
for _, ch := range result.Channels {
_, _, chOpts, found, err := h.cfgContainer.ChannelOptions(ch)
if err != nil {
return centrifuge.ConnectReply{}, ConnectExtra{}, err
}
if !found {
log.Warn().Str("client", e.ClientID).Str("channel", ch).Msg("unknown channel in connect result channels")
return centrifuge.ConnectReply{}, ConnectExtra{}, centrifuge.ErrorUnknownChannel
}
reply.Subscriptions[ch] = centrifuge.SubscribeOptions{
EmitPresence: chOpts.Presence,
EmitJoinLeave: chOpts.JoinLeave,
PushJoinLeave: chOpts.ForcePushJoinLeave,
EnableRecovery: chOpts.ForceRecovery,
EnablePositioning: chOpts.ForcePositioning,
RecoveryMode: chOpts.GetRecoveryMode(),
Source: subsource.ConnectProxy,
HistoryMetaTTL: chOpts.HistoryMetaTTL.ToDuration(),
AllowedDeltaTypes: chOpts.AllowedDeltaTypes,
AllowTagsFilter: chOpts.AllowTagsFilter,
}
}
}
if len(result.Subs) > 0 {
if reply.Subscriptions == nil {
reply.Subscriptions = make(map[string]centrifuge.SubscribeOptions, len(result.Subs))
}
for ch, options := range result.Subs {
_, _, chOpts, found, err := h.cfgContainer.ChannelOptions(ch)
if err != nil {
return centrifuge.ConnectReply{}, ConnectExtra{}, err
}
if !found {
log.Warn().Str("client", e.ClientID).Str("channel", ch).Msg("unknown channel in connect result subs")
return centrifuge.ConnectReply{}, ConnectExtra{}, centrifuge.ErrorUnknownChannel
}
var chInfo []byte
if options.B64Info != "" {
byteInfo, err := base64.StdEncoding.DecodeString(options.B64Info)
if err != nil {
return centrifuge.ConnectReply{}, ConnectExtra{}, err
}
chInfo = byteInfo
} else {
chInfo = options.Info
}
var chData []byte
if options.B64Data != "" {
byteInfo, err := base64.StdEncoding.DecodeString(options.B64Data)
if err != nil {
return centrifuge.ConnectReply{}, ConnectExtra{}, err
}
chData = byteInfo
} else {
chData = options.Data
}
presence := chOpts.Presence
if options.Override != nil && options.Override.Presence != nil {
presence = options.Override.Presence.Value
}
joinLeave := chOpts.JoinLeave
if options.Override != nil && options.Override.JoinLeave != nil {
joinLeave = options.Override.JoinLeave.Value
}
pushJoinLeave := chOpts.ForcePushJoinLeave
if options.Override != nil && options.Override.ForcePushJoinLeave != nil {
pushJoinLeave = options.Override.ForcePushJoinLeave.Value
}
recovery := chOpts.ForceRecovery
if options.Override != nil && options.Override.ForceRecovery != nil {
recovery = options.Override.ForceRecovery.Value
}
positioning := chOpts.ForcePositioning
if options.Override != nil && options.Override.ForcePositioning != nil {
positioning = options.Override.ForcePositioning.Value
}
recoveryMode := chOpts.GetRecoveryMode()
reply.Subscriptions[ch] = centrifuge.SubscribeOptions{
ChannelInfo: chInfo,
EmitPresence: presence,
EmitJoinLeave: joinLeave,
PushJoinLeave: pushJoinLeave,
EnableRecovery: recovery,
EnablePositioning: positioning,
RecoveryMode: recoveryMode,
AutoCacheRecover: options.CacheRecover,
Data: chData,
Source: subsource.ConnectProxy,
HistoryMetaTTL: chOpts.HistoryMetaTTL.ToDuration(),
AllowedDeltaTypes: chOpts.AllowedDeltaTypes,
AllowTagsFilter: chOpts.AllowTagsFilter,
}
}
}
if result.Meta != nil {
reply.Storage = map[string]any{
clientstorage.KeyMeta: json.RawMessage(result.Meta),
}
}
return reply, ConnectExtra{}, nil
}
}

View File

@@ -0,0 +1,449 @@
package proxy
import (
"context"
"encoding/base64"
"fmt"
"log"
"net"
"net/http"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/clientcontext"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifuge"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type grpcConnHandleTestCase struct {
*tools.CommonGRPCProxyTestCase
connectProxyHandler *ConnectHandler
}
func getTestGrpcProxy(commonProxyTestCase *tools.CommonGRPCProxyTestCase) configtypes.Proxy {
return configtypes.Proxy{
// Using passthrough is required for in-memory bufconn since grpc-go v1.63.0.
// See https://github.com/grpc/grpc-go/issues/7091.
Endpoint: "passthrough:///" + commonProxyTestCase.Listener.Addr().String(),
Timeout: configtypes.Duration(5 * time.Second),
TestGrpcDialer: func(ctx context.Context, s string) (net.Conn, error) {
return commonProxyTestCase.Listener.Dial()
},
}
}
func getTestHttpProxy(commonProxyTestCase *tools.CommonHTTPProxyTestCase, endpoint string) configtypes.Proxy {
return configtypes.Proxy{
Endpoint: commonProxyTestCase.Server.URL + endpoint,
Timeout: configtypes.Duration(5 * time.Second),
ProxyCommon: configtypes.ProxyCommon{
HTTP: configtypes.ProxyCommonHTTP{
StaticHeaders: map[string]string{
"X-Test": "test",
},
StatusToCodeTransforms: []configtypes.HttpStatusToCodeTransform{
{StatusCode: 404, ToDisconnect: configtypes.TransformDisconnect{Code: 4504, Reason: "not found"}},
},
},
},
}
}
func newConnHandleGRPCTestCase(ctx context.Context, proxyGRPCServer proxyGRPCTestServer) grpcConnHandleTestCase {
commonProxyTestCase := tools.NewCommonGRPCProxyTestCase(ctx, proxyGRPCServer)
connectProxy, err := NewGRPCConnectProxy("default", getTestGrpcProxy(commonProxyTestCase))
if err != nil {
log.Fatalln("could not create grpc connect proxy: ", err)
}
cfgContainer, err := config.NewContainer(config.DefaultConfig())
if err != nil {
panic(err)
}
connectProxyHandler := NewConnectHandler(ConnectHandlerConfig{
Proxy: connectProxy,
}, cfgContainer)
return grpcConnHandleTestCase{commonProxyTestCase, connectProxyHandler}
}
type httpConnHandleTestCase struct {
*tools.CommonHTTPProxyTestCase
connectProxyHandler *ConnectHandler
}
func newConnHandleHTTPTestCase(ctx context.Context, endpoint string) httpConnHandleTestCase {
commonProxyTestCase := tools.NewCommonHTTPProxyTestCase(ctx)
connectProxy, err := NewHTTPConnectProxy(getTestHttpProxy(commonProxyTestCase, endpoint))
if err != nil {
log.Fatalln("could not create http connect proxy: ", err)
}
cfgContainer, err := config.NewContainer(config.DefaultConfig())
if err != nil {
panic(err)
}
connectProxyHandler := NewConnectHandler(ConnectHandlerConfig{
Proxy: connectProxy,
}, cfgContainer)
return httpConnHandleTestCase{commonProxyTestCase, connectProxyHandler}
}
type connHandleTestCase struct {
connectProxyHandler *ConnectHandler
protocol string
node *centrifuge.Node
}
func (c connHandleTestCase) invokeHandle(ctx context.Context) (reply centrifuge.ConnectReply, err error) {
connectEvent := centrifuge.ConnectEvent{
Transport: tools.NewTestTransport(),
}
connHandler := c.connectProxyHandler.Handle()
reply, _, err = connHandler(ctx, connectEvent)
return reply, err
}
func newConnHandleTestCases(httpTestCase httpConnHandleTestCase, grpcTestCase grpcConnHandleTestCase) []connHandleTestCase {
return []connHandleTestCase{
{
connectProxyHandler: grpcTestCase.connectProxyHandler,
node: grpcTestCase.Node,
protocol: "grpc",
},
{
connectProxyHandler: httpTestCase.connectProxyHandler,
node: httpTestCase.Node,
protocol: "http",
},
}
}
func TestHandleConnectWithEmptyReply(t *testing.T) {
grpcTestCase := newConnHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("", proxyGRPCTestServerOptions{}))
defer grpcTestCase.Teardown()
httpTestCase := newConnHandleHTTPTestCase(context.Background(), "/proxy")
httpTestCase.Mux.HandleFunc("/proxy", func(w http.ResponseWriter, req *http.Request) {
require.Equal(t, "test", req.Header.Get("X-Test"))
_, _ = w.Write([]byte(`{}`))
})
defer httpTestCase.Teardown()
cases := newConnHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle(context.Background())
require.NoError(t, err, c.protocol)
require.Equal(t, centrifuge.ConnectReply{}, reply, c.protocol)
}
}
func TestHandleConnectWithResult(t *testing.T) {
customData := "test"
customDataB64 := base64.StdEncoding.EncodeToString([]byte(customData))
opts := proxyGRPCTestServerOptions{
User: "56",
ExpireAt: 1565436268,
B64Data: customDataB64,
}
grpcTestCase := newConnHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("result", opts))
defer grpcTestCase.Teardown()
httpTestCase := newConnHandleHTTPTestCase(context.Background(), "/proxy")
httpTestCase.Mux.HandleFunc("/proxy", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(fmt.Sprintf(`{"result": {"user": "56", "expire_at": 1565436268, "b64data": "%s"}}`, customDataB64)))
})
defer httpTestCase.Teardown()
cases := newConnHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
expectedReply := centrifuge.ConnectReply{
Credentials: &centrifuge.Credentials{
UserID: "56",
ExpireAt: 1565436268,
},
Data: []byte(customData),
}
reply, err := c.invokeHandle(context.Background())
require.NoError(t, err, c.protocol)
require.Equal(t, expectedReply, reply, c.protocol)
}
}
func TestHandleConnectWithInvalidCustomData(t *testing.T) {
opts := proxyGRPCTestServerOptions{
User: "56",
ExpireAt: 1565436268,
B64Data: "invalid data",
}
grpcTestCase := newConnHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("result", opts))
defer grpcTestCase.Teardown()
httpTestCase := newConnHandleHTTPTestCase(context.Background(), "/proxy")
httpTestCase.Mux.HandleFunc("/proxy", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"result": {"user": "56", "expire_at": 1565436268, "b64data": "invalid data"}}`))
})
defer httpTestCase.Teardown()
cases := newConnHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle(context.Background())
require.ErrorIs(t, centrifuge.ErrorInternal, err, c.protocol)
require.Equal(t, centrifuge.ConnectReply{}, reply, c.protocol)
}
}
func TestHandleConnectWithContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
grpcTestCase := newConnHandleGRPCTestCase(ctx, proxyGRPCTestServer{})
defer grpcTestCase.Teardown()
httpTestCase := newConnHandleHTTPTestCase(ctx, "/proxy")
httpTestCase.Mux.HandleFunc("/proxy", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{}`))
})
cases := newConnHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle(ctx)
require.ErrorIs(t, centrifuge.DisconnectConnectionClosed, err, c.protocol)
require.Equal(t, centrifuge.ConnectReply{}, reply, c.protocol)
}
}
func TestHandleConnectWithoutProxyServerStart(t *testing.T) {
grpcTestCase := newConnHandleGRPCTestCase(context.Background(), proxyGRPCTestServer{})
grpcTestCase.Teardown()
httpTestCase := newConnHandleHTTPTestCase(context.Background(), "/proxy")
httpTestCase.Teardown()
cases := newConnHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle(context.Background())
if c.protocol == "grpc" {
st, ok := status.FromError(err)
require.True(t, ok, c.protocol)
require.Equal(t, codes.Unavailable, st.Code(), c.protocol)
} else {
require.Error(t, err, c.protocol)
}
require.Equal(t, centrifuge.ConnectReply{}, reply, c.protocol)
}
}
func TestHandleConnectWithProxyServerCustomDisconnect(t *testing.T) {
grpcTestCase := newConnHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("custom disconnect", proxyGRPCTestServerOptions{}))
defer grpcTestCase.Teardown()
httpTestCase := newConnHandleHTTPTestCase(context.Background(), "/proxy")
httpTestCase.Mux.HandleFunc("/proxy", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"disconnect": {"code": 4000, "reconnect": false, "reason": "custom disconnect"}}`))
})
defer httpTestCase.Teardown()
cases := newConnHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
expectedErr := centrifuge.Disconnect{
Code: 4000,
Reason: "custom disconnect",
}
reply, err := c.invokeHandle(context.Background())
require.NotNil(t, err, c.protocol)
require.Equal(t, expectedErr.Error(), err.Error(), c.protocol)
require.Equal(t, centrifuge.ConnectReply{}, reply, c.protocol)
}
}
func TestHandleConnectWithProxyServerCustomError(t *testing.T) {
grpcTestCase := newConnHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("custom error", proxyGRPCTestServerOptions{}))
defer grpcTestCase.Teardown()
httpTestCase := newConnHandleHTTPTestCase(context.Background(), "/proxy")
httpTestCase.Mux.HandleFunc("/proxy", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"error": {"code": 1000, "message": "custom error"}}`))
})
defer httpTestCase.Teardown()
cases := newConnHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
expectedErr := centrifuge.Error{
Code: 1000,
Message: "custom error",
}
reply, err := c.invokeHandle(context.Background())
require.NotNil(t, err, c.protocol)
require.Equal(t, expectedErr.Error(), err.Error(), c.protocol)
require.Equal(t, centrifuge.ConnectReply{}, reply, c.protocol)
}
}
func TestHandleConnectWithSubscription(t *testing.T) {
opts := proxyGRPCTestServerOptions{
User: "56",
Channels: []string{"test_ch"},
}
grpcTestCase := newConnHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("subscription", opts))
defer grpcTestCase.Teardown()
httpTestCase := newConnHandleHTTPTestCase(context.Background(), "/proxy")
httpTestCase.Mux.HandleFunc("/proxy", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"result": {"user": "56", "channels": ["test_ch"]}}`))
})
defer httpTestCase.Teardown()
cases := newConnHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle(context.Background())
require.NoError(t, err, c.protocol)
require.NotNil(t, reply.Subscriptions, c.protocol)
require.Len(t, reply.Subscriptions, 1, c.protocol)
require.Contains(t, reply.Subscriptions, "test_ch", c.protocol)
}
}
func TestHandleConnectWithSubscriptionRecover(t *testing.T) {
opts := proxyGRPCTestServerOptions{
User: "56",
Channels: []string{"test_ch"},
}
grpcTestCase := newConnHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("subscription with recover", opts))
defer grpcTestCase.Teardown()
httpTestCase := newConnHandleHTTPTestCase(context.Background(), "/proxy")
httpTestCase.Mux.HandleFunc("/proxy", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"result": {"user": "56", "subs": {"test_ch": {"cache_recover": true}}}}`))
})
defer httpTestCase.Teardown()
cases := newConnHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle(context.Background())
require.NoError(t, err, c.protocol)
require.NotNil(t, reply.Subscriptions, c.protocol)
require.Contains(t, reply.Subscriptions, "test_ch", c.protocol)
require.True(t, reply.Subscriptions["test_ch"].AutoCacheRecover, c.protocol)
}
}
func TestHandleConnectWithSubscriptionError(t *testing.T) {
opts := proxyGRPCTestServerOptions{
User: "56",
Channels: []string{"test_ch:test"},
}
grpcTestCase := newConnHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("subscription error", opts))
defer grpcTestCase.Teardown()
httpTestCase := newConnHandleHTTPTestCase(context.Background(), "/proxy")
httpTestCase.Mux.HandleFunc("/proxy", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"result": {"user": "56", "channels": ["test_ch:test"]}}`))
})
defer httpTestCase.Teardown()
cases := newConnHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle(context.Background())
require.ErrorIs(t, centrifuge.ErrorUnknownChannel, err, c.protocol)
require.Equal(t, centrifuge.ConnectReply{}, reply, c.protocol)
}
}
// TestHandleConnectForwardsEmulatedHeaders is an end-to-end test of the
// emulated_headers feature: a client supplies emulated headers in the
// connect frame (here via the emulated-headers context value, exactly as the
// client handler sets them from ConnectEvent.Headers), and we assert what the
// backend actually receives over a real HTTP proxy request:
// - a name listed in emulated_headers is forwarded;
// - a name listed only in http_headers (transport-only) is NOT forwardable via
// emulation, so a client-forged value never reaches the backend.
func TestHandleConnectForwardsEmulatedHeaders(t *testing.T) {
httpTestCase := tools.NewCommonHTTPProxyTestCase(context.Background())
defer httpTestCase.Teardown()
var received http.Header
httpTestCase.Mux.HandleFunc("/proxy", func(w http.ResponseWriter, req *http.Request) {
received = req.Header.Clone()
_, _ = w.Write([]byte(`{"result": {"user": "56"}}`))
})
// Mixed-case allow lists also exercise normalization end-to-end.
proxyCfg := configtypes.Proxy{
Endpoint: httpTestCase.Server.URL + "/proxy",
Timeout: configtypes.Duration(5 * time.Second),
ProxyCommon: configtypes.ProxyCommon{
HttpHeaders: []string{"X-Trusted"}, // transport-only allow list
EmulatedHeaders: []string{"X-App-Token"}, // emulated allow list
},
}
connectProxy, err := GetConnectProxy("default", proxyCfg)
require.NoError(t, err)
cfgContainer, err := config.NewContainer(config.DefaultConfig())
require.NoError(t, err)
connHandler := NewConnectHandler(ConnectHandlerConfig{Proxy: connectProxy}, cfgContainer).Handle()
// Client supplies an allowed emulated header and tries to forge a name that is
// only allowed as a transport header — the latter must not pass via emulation.
ctx := clientcontext.SetEmulatedHeadersToContext(context.Background(), map[string]string{
"x-app-token": "from-client",
"x-trusted": "forged-by-client",
"x-other": "not-allowed",
})
_, _, err = connHandler(ctx, centrifuge.ConnectEvent{Transport: tools.NewTestTransport()})
require.NoError(t, err)
require.NotNil(t, received, "backend should have been called")
require.Equal(t, "from-client", received.Get("X-App-Token"), "header in emulated_headers must be forwarded")
require.Empty(t, received.Get("X-Trusted"), "emulated value for an http_headers-only name must NOT be forwarded")
require.Empty(t, received.Get("X-Other"), "header in neither allow list must NOT be forwarded")
}
func TestHandleConnectWithHTTPCodeTransform(t *testing.T) {
grpcTestCase := newConnHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("http status code transform", proxyGRPCTestServerOptions{}))
defer grpcTestCase.Teardown()
httpTestCase := newConnHandleHTTPTestCase(context.Background(), "/proxy")
httpTestCase.Mux.HandleFunc("/proxy", func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{}`))
})
defer httpTestCase.Teardown()
cases := newConnHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
if c.protocol == "grpc" {
continue // Transforms not supported.
}
expectedErr := centrifuge.Disconnect{
Code: 4504,
Reason: "not found",
}
reply, err := c.invokeHandle(context.Background())
require.NotNil(t, err, c.protocol)
require.Equal(t, expectedErr.Error(), err.Error(), c.protocol)
require.Equal(t, centrifuge.ConnectReply{}, reply, c.protocol)
}
}

View File

@@ -0,0 +1,55 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// HTTPConnectProxy ...
type HTTPConnectProxy struct {
config Config
httpCaller HTTPCaller
}
var _ ConnectProxy = (*HTTPConnectProxy)(nil)
// NewHTTPConnectProxy ...
func NewHTTPConnectProxy(p Config) (*HTTPConnectProxy, error) {
httpClient, err := proxyHTTPClient(p, "connect_proxy")
if err != nil {
return nil, fmt.Errorf("error creating HTTP client: %w", err)
}
return &HTTPConnectProxy{
config: p,
httpCaller: NewHTTPCaller(httpClient),
}, nil
}
// Protocol ...
func (p *HTTPConnectProxy) Protocol() string {
return "http"
}
func (p *HTTPConnectProxy) Name() string {
return "default"
}
// UseBase64 ...
func (p *HTTPConnectProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// ProxyConnect proxies connect control to application backend.
func (p *HTTPConnectProxy) ProxyConnect(ctx context.Context, req *proxyproto.ConnectRequest) (*proxyproto.ConnectResponse, error) {
data, err := httpEncoder.EncodeConnectRequest(req)
if err != nil {
return nil, err
}
respData, err := p.httpCaller.CallHTTP(ctx, p.config.Endpoint, httpRequestHeaders(ctx, p.config), data)
if err != nil {
return transformConnectResponse(err, p.config.HTTP.StatusToCodeTransforms)
}
return httpDecoder.DecodeConnectResponse(respData)
}

126
internal/proxy/grpc.go Normal file
View File

@@ -0,0 +1,126 @@
package proxy
import (
"context"
"fmt"
"net/url"
"slices"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/clientcontext"
"github.com/centrifugal/centrifugo/v6/internal/middleware"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/encoding/gzip"
"google.golang.org/grpc/metadata"
)
type rpcCredentials struct {
key string
value string
}
func (t rpcCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
return map[string]string{
t.key: t.value,
}, nil
}
func (t rpcCredentials) RequireTransportSecurity() bool {
return false
}
func getGrpcHost(endpoint string) (string, error) {
var host string
if strings.HasPrefix(endpoint, "grpc://") {
u, err := url.Parse(endpoint)
if err != nil {
return "", err
}
host = u.Host
} else {
host = endpoint
}
return host, nil
}
func getDialOpts(name string, p Config) ([]grpc.DialOption, error) {
var dialOpts []grpc.DialOption
if p.GRPC.CredentialsKey != "" {
dialOpts = append(dialOpts, grpc.WithPerRPCCredentials(&rpcCredentials{
key: p.GRPC.CredentialsKey,
value: p.GRPC.CredentialsValue,
}))
}
if p.GRPC.TLS.Enabled {
tlsConfig, err := p.GRPC.TLS.ToGoTLSConfig("proxy_grpc:" + name)
if err != nil {
return nil, fmt.Errorf("failed to create TLS config %v", err)
}
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
}
if p.GRPC.Compression {
dialOpts = append(dialOpts, grpc.WithDefaultCallOptions(grpc.UseCompressor(gzip.Name)))
}
if p.TestGrpcDialer != nil {
dialOpts = append(dialOpts, grpc.WithContextDialer(p.TestGrpcDialer))
}
return dialOpts, nil
}
func grpcRequestContext(ctx context.Context, proxy Config) context.Context {
md := requestMetadata(ctx, proxy.HttpHeaders, proxy.GrpcMetadata, emulatedHeaderAllowList(proxy), proxy.GRPC.StaticMetadata)
return metadata.NewOutgoingContext(ctx, md)
}
func requestMetadata(ctx context.Context, allowedHeaders, allowedMetaKeys, allowedEmulatedHeaders []string, staticMetadata map[string]string) metadata.MD {
requestMD := metadata.MD{}
// Set static metadata first, so that dynamic metadata can override it.
for k, v := range staticMetadata {
requestMD.Set(k, v)
}
// Emulated headers come from the client connect frame and are client-controlled,
// so they are forwarded only when listed in emulated_headers - never via
// the transport-level http_headers allow list.
emulatedHeaders, _ := clientcontext.GetEmulatedHeadersFromContext(ctx)
var droppedEmulated bool
for k, v := range emulatedHeaders {
lk := strings.ToLower(k)
if slices.Contains(allowedEmulatedHeaders, lk) {
requestMD.Set(k, v)
} else if slices.Contains(allowedHeaders, lk) {
// Listed in http_headers but not emulated_headers: this would
// have been forwarded before v6.9.0. Flag for a throttled migration hint.
droppedEmulated = true
}
}
if droppedEmulated {
logDroppedEmulatedHeadersHint(emulatedHeaders, allowedHeaders, allowedEmulatedHeaders)
}
httpHeaders, hasHTTPHeaders := middleware.GetHeadersFromContext(ctx)
for k, vv := range httpHeaders {
if slices.Contains(allowedHeaders, strings.ToLower(k)) {
requestMD.Set(k, vv...)
}
}
if !hasHTTPHeaders {
md, _ := metadata.FromIncomingContext(ctx)
for k, vv := range md {
if slices.Contains(allowedMetaKeys, k) {
requestMD[k] = vv
}
}
}
return requestMD
}

181
internal/proxy/http.go Normal file
View File

@@ -0,0 +1,181 @@
package proxy
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net/http"
"slices"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/clientcontext"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/middleware"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"google.golang.org/grpc/metadata"
)
type baseRequestHTTP struct {
ClientID string `json:"client"`
Transport string `json:"transport"`
Protocol string `json:"protocol"`
Encoding string `json:"encoding"`
}
var httpEncoder = &proxyproto.JSONEncoder{}
var httpDecoder = &proxyproto.JSONDecoder{}
// DefaultMaxIdleConnsPerHost is a reasonable value for all HTTP clients.
const DefaultMaxIdleConnsPerHost = 255
// HTTPCaller is responsible for calling HTTP.
type HTTPCaller interface {
CallHTTP(context.Context, string, http.Header, []byte) ([]byte, error)
}
type httpCaller struct {
Endpoint string
HTTPClient *http.Client
}
// NewHTTPCaller creates new HTTPCaller.
func NewHTTPCaller(httpClient *http.Client) HTTPCaller {
return &httpCaller{
HTTPClient: httpClient,
}
}
func proxyHTTPClient(p configtypes.Proxy, logTraceEntity string) (*http.Client, error) {
var tlsConfig *tls.Config
if p.HTTP.TLS.Enabled {
var err error
tlsConfig, err = p.HTTP.TLS.ToGoTLSConfig(logTraceEntity)
if err != nil {
return nil, fmt.Errorf("error creating TLS config: %w", err)
}
}
return &http.Client{
Transport: &http.Transport{
MaxIdleConnsPerHost: DefaultMaxIdleConnsPerHost,
TLSClientConfig: tlsConfig,
},
Timeout: p.Timeout.ToDuration(),
}, nil
}
type statusCodeError struct {
Code int
}
func (e *statusCodeError) Error() string {
return fmt.Sprintf("unexpected HTTP status code: %d", e.Code)
}
func (c *httpCaller) CallHTTP(ctx context.Context, endpoint string, header http.Header, reqData []byte) ([]byte, error) {
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(reqData))
if err != nil {
return nil, fmt.Errorf("error constructing HTTP request: %w", err)
}
req.Header = header
resp, err := c.HTTPClient.Do(req.WithContext(ctx))
if err != nil {
return nil, fmt.Errorf("HTTP request error: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, &statusCodeError{resp.StatusCode}
}
respData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading HTTP body: %w", err)
}
return respData, nil
}
func transformHTTPStatusError(err error, transforms []configtypes.HttpStatusToCodeTransform) (*proxyproto.Error, *proxyproto.Disconnect) {
if len(transforms) == 0 {
return nil, nil
}
var statusErr *statusCodeError
if !errors.As(err, &statusErr) {
return nil, nil
}
for _, t := range transforms {
if t.StatusCode == statusErr.Code {
if t.ToError.Code > 0 {
return &proxyproto.Error{
Code: t.ToError.Code,
Message: t.ToError.Message,
Temporary: t.ToError.Temporary,
}, nil
}
if t.ToDisconnect.Code > 0 {
return nil, &proxyproto.Disconnect{
Code: t.ToDisconnect.Code,
Reason: t.ToDisconnect.Reason,
}
}
}
}
return nil, nil
}
func httpRequestHeaders(ctx context.Context, proxy Config) http.Header {
return requestHeaders(ctx, proxy.HttpHeaders, proxy.GrpcMetadata, emulatedHeaderAllowList(proxy), proxy.HTTP.StaticHeaders)
}
func requestHeaders(ctx context.Context, allowedHeaders, allowedMetaKeys, allowedEmulatedHeaders []string, staticHeaders map[string]string) http.Header {
headers := http.Header{}
// Set static headers first, so that dynamic headers can override them.
for k, v := range staticHeaders {
headers.Set(k, v)
}
// Emulated headers come from the client connect frame and are client-controlled,
// so they are forwarded only when listed in emulated_headers - never via
// the transport-level http_headers allow list.
emulatedHeaders, _ := clientcontext.GetEmulatedHeadersFromContext(ctx)
var droppedEmulated bool
for k, v := range emulatedHeaders {
lk := strings.ToLower(k)
if slices.Contains(allowedEmulatedHeaders, lk) {
headers.Set(k, v)
} else if slices.Contains(allowedHeaders, lk) {
// Listed in http_headers but not emulated_headers: this would
// have been forwarded before v6.9.0. Flag for a throttled migration hint.
droppedEmulated = true
}
}
if droppedEmulated {
logDroppedEmulatedHeadersHint(emulatedHeaders, allowedHeaders, allowedEmulatedHeaders)
}
httpHeaders, hasHTTPHeaders := middleware.GetHeadersFromContext(ctx)
for k, vv := range httpHeaders {
if slices.Contains(allowedHeaders, strings.ToLower(k)) {
headers[k] = vv
}
}
if !hasHTTPHeaders {
md, _ := metadata.FromIncomingContext(ctx)
for k, vv := range md {
if slices.Contains(allowedMetaKeys, k) {
// Canonicalize the lowercase metadata key so the transport value
// lands on the same map key as emulated values written via Set
// and overrides them - otherwise both would be sent as duplicate
// headers with different casings.
headers[http.CanonicalHeaderKey(k)] = vv
}
}
}
headers.Set("Content-Type", "application/json")
return headers
}

View File

@@ -0,0 +1,18 @@
package proxy
import (
"context"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// MapPublishProxy allows to send MapPublish requests.
type MapPublishProxy interface {
ProxyMapPublish(context.Context, *proxyproto.MapPublishRequest) (*proxyproto.MapPublishResponse, error)
// Protocol for metrics and logging.
Protocol() string
// UseBase64 for bytes in requests from Centrifugo to application backend.
UseBase64() bool
// IncludeMeta ...
IncludeMeta() bool
}

View File

@@ -0,0 +1,60 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"google.golang.org/grpc"
)
// GRPCMapPublishProxy ...
type GRPCMapPublishProxy struct {
config Config
client proxyproto.CentrifugoProxyClient
}
var _ MapPublishProxy = (*GRPCMapPublishProxy)(nil)
// NewGRPCMapPublishProxy ...
func NewGRPCMapPublishProxy(name string, p Config) (*GRPCMapPublishProxy, error) {
host, err := getGrpcHost(p.Endpoint)
if err != nil {
return nil, fmt.Errorf("error getting grpc host: %v", err)
}
dialOpts, err := getDialOpts(name, p)
if err != nil {
return nil, fmt.Errorf("error creating GRPC dial options: %v", err)
}
conn, err := grpc.NewClient(host, dialOpts...)
if err != nil {
return nil, fmt.Errorf("error connecting to GRPC proxy server: %v", err)
}
return &GRPCMapPublishProxy{
config: p,
client: proxyproto.NewCentrifugoProxyClient(conn),
}, nil
}
// ProxyMapPublish proxies MapPublish to application backend.
func (p *GRPCMapPublishProxy) ProxyMapPublish(ctx context.Context, req *proxyproto.MapPublishRequest) (*proxyproto.MapPublishResponse, error) {
ctx, cancel := context.WithTimeout(ctx, p.config.Timeout.ToDuration())
defer cancel()
return p.client.MapPublish(grpcRequestContext(ctx, p.config), req)
}
// Protocol ...
func (p *GRPCMapPublishProxy) Protocol() string {
return "grpc"
}
// UseBase64 ...
func (p *GRPCMapPublishProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *GRPCMapPublishProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

View File

@@ -0,0 +1,175 @@
package proxy
import (
"encoding/base64"
"time"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
"github.com/rs/zerolog/log"
)
// MapPublishHandlerConfig ...
type MapPublishHandlerConfig struct {
Proxies map[string]MapPublishProxy
}
// MapPublishHandler ...
type MapPublishHandler struct {
config MapPublishHandlerConfig
summary map[string]prometheus.Observer
histogram map[string]prometheus.Observer
errors map[string]prometheus.Counter
inflight map[string]prometheus.Gauge
}
// NewMapPublishHandler ...
func NewMapPublishHandler(c MapPublishHandlerConfig) *MapPublishHandler {
h := &MapPublishHandler{
config: c,
}
summary := map[string]prometheus.Observer{}
histogram := map[string]prometheus.Observer{}
errors := map[string]prometheus.Counter{}
inflight := map[string]prometheus.Gauge{}
for name, p := range c.Proxies {
summary[name] = metrics.ProxyCallDurationSummary.WithLabelValues(p.Protocol(), "map_publish", name)
histogram[name] = metrics.ProxyCallDurationHistogram.WithLabelValues(p.Protocol(), "map_publish", name)
errors[name] = metrics.ProxyCallErrorCount.WithLabelValues(p.Protocol(), "map_publish", name)
inflight[name] = metrics.ProxyCallInflightRequests.WithLabelValues(p.Protocol(), "map_publish", name)
}
h.summary = summary
h.histogram = histogram
h.errors = errors
h.inflight = inflight
return h
}
// MapPublishHandlerFunc ...
type MapPublishHandlerFunc func(Client, centrifuge.MapPublishEvent, configtypes.ChannelOptions, PerCallData) (centrifuge.MapPublishReply, error)
// Handle MapPublish.
func (h *MapPublishHandler) Handle(node *centrifuge.Node) MapPublishHandlerFunc {
return func(client Client, e centrifuge.MapPublishEvent, chOpts configtypes.ChannelOptions, pcd PerCallData) (centrifuge.MapPublishReply, error) {
started := time.Now()
var p MapPublishProxy
var summary prometheus.Observer
var histogram prometheus.Observer
var errors prometheus.Counter
proxyEnabled := chOpts.Map.PublishProxyEnabled
proxyName := chOpts.Map.PublishProxyName
if !proxyEnabled {
log.Info().Str("channel", e.Channel).Msg("map publish proxy not enabled for a channel")
return centrifuge.MapPublishReply{}, centrifuge.ErrorNotAvailable
}
p = h.config.Proxies[proxyName]
summary = h.summary[proxyName]
histogram = h.histogram[proxyName]
errors = h.errors[proxyName]
inflight := h.inflight[proxyName]
inflight.Inc()
defer inflight.Dec()
req := &proxyproto.MapPublishRequest{
Client: client.ID(),
Protocol: string(client.Transport().Protocol()),
Transport: client.Transport().Name(),
Encoding: getEncoding(p.UseBase64()),
User: client.UserID(),
Channel: e.Channel,
Key: e.Key,
}
if p.IncludeMeta() && pcd.Meta != nil {
req.Meta = proxyproto.Raw(pcd.Meta)
}
if !p.UseBase64() {
req.Data = e.Data
} else {
req.B64Data = base64.StdEncoding.EncodeToString(e.Data)
}
mapPublishRep, err := p.ProxyMapPublish(client.Context(), req)
duration := time.Since(started).Seconds()
if err != nil {
select {
case <-client.Context().Done():
return centrifuge.MapPublishReply{}, centrifuge.DisconnectConnectionClosed
default:
}
summary.Observe(duration)
histogram.Observe(duration)
errors.Inc()
log.Error().Err(err).Str("client", client.ID()).Str("channel", e.Channel).Msg("error proxying map publish")
return centrifuge.MapPublishReply{}, err
}
summary.Observe(duration)
histogram.Observe(duration)
if mapPublishRep.Disconnect != nil {
return centrifuge.MapPublishReply{}, proxyproto.DisconnectFromProto(mapPublishRep.Disconnect)
}
if mapPublishRep.Error != nil {
return centrifuge.MapPublishReply{}, proxyproto.ErrorFromProto(mapPublishRep.Error)
}
key := e.Key
data := e.Data
var tags map[string]string
var keyMode centrifuge.KeyMode
var idempotencyKey string
var useDelta bool
var version uint64
var versionEpoch string
if mapPublishRep.Result != nil {
if mapPublishRep.Result.Key != "" {
key = mapPublishRep.Result.Key
}
if mapPublishRep.Result.Data != nil {
data = mapPublishRep.Result.Data
} else if mapPublishRep.Result.B64Data != "" {
decodedData, err := base64.StdEncoding.DecodeString(mapPublishRep.Result.B64Data)
if err != nil {
log.Error().Err(err).Str("client", client.ID()).Msg("error decoding base64 data")
return centrifuge.MapPublishReply{}, centrifuge.ErrorInternal
}
data = decodedData
}
tags = mapPublishRep.Result.Tags
keyMode = centrifuge.KeyMode(mapPublishRep.Result.KeyMode)
idempotencyKey = mapPublishRep.Result.IdempotencyKey
useDelta = mapPublishRep.Result.Delta
version = mapPublishRep.Result.Version
versionEpoch = mapPublishRep.Result.VersionEpoch
}
// The proxy may replace the data client sent, so validate the final data
// against the channel format – same policy as for the client-supplied one.
if err := config.ValidatePublicationData(data, chOpts.PublicationDataFormat); err != nil {
log.Info().Err(err).Str("channel", e.Channel).Str("client", client.ID()).Msg("map publish proxy data validation failed")
return centrifuge.MapPublishReply{}, centrifuge.ErrorBadRequest
}
result, err := node.MapPublish(
client.Context(), e.Channel, key,
centrifuge.MapPublishOptions{
Data: data,
Tags: tags,
KeyMode: keyMode,
IdempotencyKey: idempotencyKey,
UseDelta: useDelta,
Version: version,
VersionEpoch: versionEpoch,
ClientInfo: e.ClientInfo,
},
)
return centrifuge.MapPublishReply{Result: &result}, err
}
}

View File

@@ -0,0 +1,75 @@
package proxy
import (
"context"
"encoding/base64"
"fmt"
"log"
"net/http"
"testing"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifuge"
"github.com/stretchr/testify/require"
)
type httpMapPublishHandleTestCase struct {
*tools.CommonHTTPProxyTestCase
mapPublishProxyHandler *MapPublishHandler
channelOpts configtypes.ChannelOptions
}
func newMapPublishHandleHTTPTestCase(ctx context.Context, endpoint string, opts configtypes.ChannelOptions) httpMapPublishHandleTestCase {
commonProxyTestCase := tools.NewCommonHTTPProxyTestCase(ctx)
mapPublishProxy, err := NewHTTPMapPublishProxy(getTestHttpProxy(commonProxyTestCase, endpoint))
if err != nil {
log.Fatalln("could not create http map publish proxy: ", err)
}
mapPublishProxyHandler := NewMapPublishHandler(MapPublishHandlerConfig{
Proxies: map[string]MapPublishProxy{
"test": mapPublishProxy,
},
})
return httpMapPublishHandleTestCase{commonProxyTestCase, mapPublishProxyHandler, opts}
}
func (c httpMapPublishHandleTestCase) invokeHandle(event centrifuge.MapPublishEvent) (centrifuge.MapPublishReply, error) {
handler := c.mapPublishProxyHandler.Handle(c.Node)
return handler(c.Client, event, c.channelOpts, PerCallData{})
}
// TestHandleMapPublishProxyResultDataValidated ensures data returned by a map
// publish proxy is validated against the channel publication_data_format — the
// proxy can replace what the client sent, so checking only the client data in
// the client handler is not enough.
func TestHandleMapPublishProxyResultDataValidated(t *testing.T) {
// Not a JSON object, while the channel requires one.
badDataB64 := base64.StdEncoding.EncodeToString([]byte(`"just a string"`))
chOpts := configtypes.ChannelOptions{
SubscriptionType: "map",
PublicationDataFormat: configtypes.PublicationDataFormatJSONObject,
Map: configtypes.MapConfig{
Mode: "persistent",
PublishProxyEnabled: true,
PublishProxyName: "test",
},
}
testCase := newMapPublishHandleHTTPTestCase(context.Background(), "/map_publish", chOpts)
testCase.Mux.HandleFunc("/map_publish", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(fmt.Sprintf(`{"result": {"key": "k", "b64data": "%s"}}`, badDataB64)))
})
defer testCase.Teardown()
_, err := testCase.invokeHandle(centrifuge.MapPublishEvent{
Channel: "test_channel",
Key: "k",
Data: []byte(`{"valid":"object"}`), // client data is fine, proxy data is not
})
require.Equal(t, centrifuge.ErrorBadRequest, err)
}

View File

@@ -0,0 +1,70 @@
package proxy
import (
"context"
"encoding/json"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// MapPublishRequestHTTP ...
type MapPublishRequestHTTP struct {
baseRequestHTTP
UserID string `json:"user"`
Channel string `json:"channel"`
Key string `json:"key"`
Data json.RawMessage `json:"data,omitempty"`
// Base64Data to proxy binary data.
Base64Data string `json:"b64data,omitempty"`
}
// HTTPMapPublishProxy ...
type HTTPMapPublishProxy struct {
config Config
httpCaller HTTPCaller
}
var _ MapPublishProxy = (*HTTPMapPublishProxy)(nil)
// NewHTTPMapPublishProxy ...
func NewHTTPMapPublishProxy(p Config) (*HTTPMapPublishProxy, error) {
httpClient, err := proxyHTTPClient(p, "map_publish_proxy")
if err != nil {
return nil, fmt.Errorf("error creating HTTP client: %w", err)
}
return &HTTPMapPublishProxy{
httpCaller: NewHTTPCaller(httpClient),
config: p,
}, nil
}
// ProxyMapPublish proxies MapPublish to application backend.
func (p *HTTPMapPublishProxy) ProxyMapPublish(ctx context.Context, req *proxyproto.MapPublishRequest) (*proxyproto.MapPublishResponse, error) {
data, err := httpEncoder.EncodeMapPublishRequest(req)
if err != nil {
return nil, err
}
respData, err := p.httpCaller.CallHTTP(ctx, p.config.Endpoint, httpRequestHeaders(ctx, p.config), data)
if err != nil {
return transformMapPublishResponse(err, p.config.HTTP.StatusToCodeTransforms)
}
return httpDecoder.DecodeMapPublishResponse(respData)
}
// Protocol ...
func (p *HTTPMapPublishProxy) Protocol() string {
return "http"
}
// UseBase64 ...
func (p *HTTPMapPublishProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *HTTPMapPublishProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

View File

@@ -0,0 +1,18 @@
package proxy
import (
"context"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// MapRemoveProxy allows to send MapRemove requests.
type MapRemoveProxy interface {
ProxyMapRemove(context.Context, *proxyproto.MapRemoveRequest) (*proxyproto.MapRemoveResponse, error)
// Protocol for metrics and logging.
Protocol() string
// UseBase64 for bytes in requests from Centrifugo to application backend.
UseBase64() bool
// IncludeMeta ...
IncludeMeta() bool
}

View File

@@ -0,0 +1,60 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"google.golang.org/grpc"
)
// GRPCMapRemoveProxy ...
type GRPCMapRemoveProxy struct {
config Config
client proxyproto.CentrifugoProxyClient
}
var _ MapRemoveProxy = (*GRPCMapRemoveProxy)(nil)
// NewGRPCMapRemoveProxy ...
func NewGRPCMapRemoveProxy(name string, p Config) (*GRPCMapRemoveProxy, error) {
host, err := getGrpcHost(p.Endpoint)
if err != nil {
return nil, fmt.Errorf("error getting grpc host: %v", err)
}
dialOpts, err := getDialOpts(name, p)
if err != nil {
return nil, fmt.Errorf("error creating GRPC dial options: %v", err)
}
conn, err := grpc.NewClient(host, dialOpts...)
if err != nil {
return nil, fmt.Errorf("error connecting to GRPC proxy server: %v", err)
}
return &GRPCMapRemoveProxy{
config: p,
client: proxyproto.NewCentrifugoProxyClient(conn),
}, nil
}
// ProxyMapRemove proxies MapRemove to application backend.
func (p *GRPCMapRemoveProxy) ProxyMapRemove(ctx context.Context, req *proxyproto.MapRemoveRequest) (*proxyproto.MapRemoveResponse, error) {
ctx, cancel := context.WithTimeout(ctx, p.config.Timeout.ToDuration())
defer cancel()
return p.client.MapRemove(grpcRequestContext(ctx, p.config), req)
}
// Protocol ...
func (p *GRPCMapRemoveProxy) Protocol() string {
return "grpc"
}
// UseBase64 ...
func (p *GRPCMapRemoveProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *GRPCMapRemoveProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

View File

@@ -0,0 +1,136 @@
package proxy
import (
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
"github.com/rs/zerolog/log"
)
// MapRemoveHandlerConfig ...
type MapRemoveHandlerConfig struct {
Proxies map[string]MapRemoveProxy
}
// MapRemoveHandler ...
type MapRemoveHandler struct {
config MapRemoveHandlerConfig
summary map[string]prometheus.Observer
histogram map[string]prometheus.Observer
errors map[string]prometheus.Counter
inflight map[string]prometheus.Gauge
}
// NewMapRemoveHandler ...
func NewMapRemoveHandler(c MapRemoveHandlerConfig) *MapRemoveHandler {
h := &MapRemoveHandler{
config: c,
}
summary := map[string]prometheus.Observer{}
histogram := map[string]prometheus.Observer{}
errors := map[string]prometheus.Counter{}
inflight := map[string]prometheus.Gauge{}
for name, p := range c.Proxies {
summary[name] = metrics.ProxyCallDurationSummary.WithLabelValues(p.Protocol(), "map_remove", name)
histogram[name] = metrics.ProxyCallDurationHistogram.WithLabelValues(p.Protocol(), "map_remove", name)
errors[name] = metrics.ProxyCallErrorCount.WithLabelValues(p.Protocol(), "map_remove", name)
inflight[name] = metrics.ProxyCallInflightRequests.WithLabelValues(p.Protocol(), "map_remove", name)
}
h.summary = summary
h.histogram = histogram
h.errors = errors
h.inflight = inflight
return h
}
// MapRemoveHandlerFunc ...
type MapRemoveHandlerFunc func(Client, centrifuge.MapRemoveEvent, configtypes.ChannelOptions, PerCallData) (centrifuge.MapRemoveReply, error)
// Handle MapRemove.
func (h *MapRemoveHandler) Handle(node *centrifuge.Node) MapRemoveHandlerFunc {
return func(client Client, e centrifuge.MapRemoveEvent, chOpts configtypes.ChannelOptions, pcd PerCallData) (centrifuge.MapRemoveReply, error) {
started := time.Now()
var p MapRemoveProxy
var summary prometheus.Observer
var histogram prometheus.Observer
var errors prometheus.Counter
proxyEnabled := chOpts.Map.RemoveProxyEnabled
proxyName := chOpts.Map.RemoveProxyName
if !proxyEnabled {
log.Info().Str("channel", e.Channel).Msg("map remove proxy not enabled for a channel")
return centrifuge.MapRemoveReply{}, centrifuge.ErrorNotAvailable
}
p = h.config.Proxies[proxyName]
summary = h.summary[proxyName]
histogram = h.histogram[proxyName]
errors = h.errors[proxyName]
inflight := h.inflight[proxyName]
inflight.Inc()
defer inflight.Dec()
req := &proxyproto.MapRemoveRequest{
Client: client.ID(),
Protocol: string(client.Transport().Protocol()),
Transport: client.Transport().Name(),
Encoding: getEncoding(p.UseBase64()),
User: client.UserID(),
Channel: e.Channel,
Key: e.Key,
}
if p.IncludeMeta() && pcd.Meta != nil {
req.Meta = proxyproto.Raw(pcd.Meta)
}
mapRemoveRep, err := p.ProxyMapRemove(client.Context(), req)
duration := time.Since(started).Seconds()
if err != nil {
select {
case <-client.Context().Done():
return centrifuge.MapRemoveReply{}, centrifuge.DisconnectConnectionClosed
default:
}
summary.Observe(duration)
histogram.Observe(duration)
errors.Inc()
log.Error().Err(err).Str("client", client.ID()).Str("channel", e.Channel).Msg("error proxying map remove")
return centrifuge.MapRemoveReply{}, err
}
summary.Observe(duration)
histogram.Observe(duration)
if mapRemoveRep.Disconnect != nil {
return centrifuge.MapRemoveReply{}, proxyproto.DisconnectFromProto(mapRemoveRep.Disconnect)
}
if mapRemoveRep.Error != nil {
return centrifuge.MapRemoveReply{}, proxyproto.ErrorFromProto(mapRemoveRep.Error)
}
key := e.Key
var tags map[string]string
var idempotencyKey string
if mapRemoveRep.Result != nil {
if mapRemoveRep.Result.Key != "" {
key = mapRemoveRep.Result.Key
}
tags = mapRemoveRep.Result.Tags
idempotencyKey = mapRemoveRep.Result.IdempotencyKey
}
result, err := node.MapRemove(
client.Context(), e.Channel, key,
centrifuge.MapRemoveOptions{
Tags: tags,
IdempotencyKey: idempotencyKey,
},
)
return centrifuge.MapRemoveReply{Result: &result}, err
}
}

View File

@@ -0,0 +1,65 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// MapRemoveRequestHTTP ...
type MapRemoveRequestHTTP struct {
baseRequestHTTP
UserID string `json:"user"`
Channel string `json:"channel"`
Key string `json:"key"`
}
// HTTPMapRemoveProxy ...
type HTTPMapRemoveProxy struct {
config Config
httpCaller HTTPCaller
}
var _ MapRemoveProxy = (*HTTPMapRemoveProxy)(nil)
// NewHTTPMapRemoveProxy ...
func NewHTTPMapRemoveProxy(p Config) (*HTTPMapRemoveProxy, error) {
httpClient, err := proxyHTTPClient(p, "map_remove_proxy")
if err != nil {
return nil, fmt.Errorf("error creating HTTP client: %w", err)
}
return &HTTPMapRemoveProxy{
httpCaller: NewHTTPCaller(httpClient),
config: p,
}, nil
}
// ProxyMapRemove proxies MapRemove to application backend.
func (p *HTTPMapRemoveProxy) ProxyMapRemove(ctx context.Context, req *proxyproto.MapRemoveRequest) (*proxyproto.MapRemoveResponse, error) {
data, err := httpEncoder.EncodeMapRemoveRequest(req)
if err != nil {
return nil, err
}
respData, err := p.httpCaller.CallHTTP(ctx, p.config.Endpoint, httpRequestHeaders(ctx, p.config), data)
if err != nil {
return transformMapRemoveResponse(err, p.config.HTTP.StatusToCodeTransforms)
}
return httpDecoder.DecodeMapRemoveResponse(respData)
}
// Protocol ...
func (p *HTTPMapRemoveProxy) Protocol() string {
return "http"
}
// UseBase64 ...
func (p *HTTPMapRemoveProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *HTTPMapRemoveProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

168
internal/proxy/proxy.go Normal file
View File

@@ -0,0 +1,168 @@
package proxy
import (
"encoding/json"
"slices"
"strings"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/rs/zerolog/log"
)
type Config = configtypes.Proxy
// droppedEmulatedHeaderLogLimiter throttles the (rare) migration hint emitted
// when a client sends an emulated header that would have been forwarded before
// v6.9.0 (listed in http_headers) but is now dropped because it is not in
// emulated_headers. Single global limiter - at most one line per interval
// across all proxies.
var droppedEmulatedHeaderLogLimiter = tools.NewIntervalLimiter(time.Minute)
// logDroppedEmulatedHeadersHint emits a one-off INFO hint listing every emulated
// header dropped for this request. It is only called when at least one header was
// dropped, and the body runs at most once per limiter interval, so it is
// effectively free on the hot path. allowedHeaders/allowedEmulatedHeaders are the
// lowercased http_headers / emulated_headers allow lists.
func logDroppedEmulatedHeadersHint(emulatedHeaders map[string]string, allowedHeaders, allowedEmulatedHeaders []string) {
if !droppedEmulatedHeaderLogLimiter.Allow() {
return
}
dropped := droppedEmulatedHeaderNames(emulatedHeaders, allowedHeaders, allowedEmulatedHeaders)
if len(dropped) == 0 {
return
}
// Informational only - deliberately no call to action. These names came from
// the client, so this may be a missed migration OR an unexpected/malicious
// client probing http_headers names; the operator must decide. Never imply the
// names should be allow-listed: their values are client-controlled.
log.Info().Strs("headers", dropped).Msg("client sent emulation headers matching http_headers but not emulated_headers - not forwarded to proxy (since v6.9.0); values are client-controlled, see headers emulation docs")
}
// droppedEmulatedHeaderNames returns the lowercased names of client-supplied
// emulated headers that are listed in http_headers but not emulated_headers
// - i.e. names that would have been forwarded before v6.9.0 and are now dropped.
func droppedEmulatedHeaderNames(emulatedHeaders map[string]string, allowedHeaders, allowedEmulatedHeaders []string) []string {
var dropped []string
for k := range emulatedHeaders {
lk := strings.ToLower(k)
if !slices.Contains(allowedEmulatedHeaders, lk) && slices.Contains(allowedHeaders, lk) {
dropped = append(dropped, lk)
}
}
return dropped
}
// normalizeProxyHeaderNames lowercases configured header/metadata name allow
// lists so matching against incoming header names is case-insensitive. It must
// be called for every proxy before use.
func normalizeProxyHeaderNames(p *Config) {
for i, header := range p.HttpHeaders {
p.HttpHeaders[i] = strings.ToLower(header)
}
for i, header := range p.EmulatedHeaders {
p.EmulatedHeaders[i] = strings.ToLower(header)
}
}
// emulatedHeaderAllowList returns the set of header names that may be sourced
// from the client headers emulation map (the client connect frame) - i.e. the
// emulated_headers allow list. The http_headers allow list is reserved for
// transport-level headers and is never sourced from emulation.
//
// A header name may legitimately appear in both http_headers and emulated_headers
// (e.g. an Authorization token a browser sends via emulation and a native client
// sends as a real transport header): the transport value is preferred and the
// emulated value is used as a fallback, so this overlap is not flagged.
func emulatedHeaderAllowList(p Config) []string {
return p.EmulatedHeaders
}
func getEncoding(useBase64 bool) string {
if useBase64 {
return "binary"
}
return "json"
}
func isHttpEndpoint(endpoint string) bool {
return strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://")
}
func GetConnectProxy(name string, p Config) (ConnectProxy, error) {
normalizeProxyHeaderNames(&p)
if isHttpEndpoint(p.Endpoint) {
return NewHTTPConnectProxy(p)
}
return NewGRPCConnectProxy(name, p)
}
func GetRefreshProxy(name string, p Config) (RefreshProxy, error) {
normalizeProxyHeaderNames(&p)
if isHttpEndpoint(p.Endpoint) {
return NewHTTPRefreshProxy(p)
}
return NewGRPCRefreshProxy(name, p)
}
func GetRpcProxy(name string, p Config) (RPCProxy, error) {
normalizeProxyHeaderNames(&p)
if isHttpEndpoint(p.Endpoint) {
return NewHTTPRPCProxy(p)
}
return NewGRPCRPCProxy(name, p)
}
func GetSubRefreshProxy(name string, p Config) (SubRefreshProxy, error) {
normalizeProxyHeaderNames(&p)
if isHttpEndpoint(p.Endpoint) {
return NewHTTPSubRefreshProxy(p)
}
return NewGRPCSubRefreshProxy(name, p)
}
func GetPublishProxy(name string, p Config) (PublishProxy, error) {
normalizeProxyHeaderNames(&p)
if isHttpEndpoint(p.Endpoint) {
return NewHTTPPublishProxy(p)
}
return NewGRPCPublishProxy(name, p)
}
func GetSubscribeProxy(name string, p Config) (SubscribeProxy, error) {
normalizeProxyHeaderNames(&p)
if isHttpEndpoint(p.Endpoint) {
return NewHTTPSubscribeProxy(p)
}
return NewGRPCSubscribeProxy(name, p)
}
func GetMapPublishProxy(name string, p Config) (MapPublishProxy, error) {
normalizeProxyHeaderNames(&p)
if isHttpEndpoint(p.Endpoint) {
return NewHTTPMapPublishProxy(p)
}
return NewGRPCMapPublishProxy(name, p)
}
func GetMapRemoveProxy(name string, p Config) (MapRemoveProxy, error) {
normalizeProxyHeaderNames(&p)
if isHttpEndpoint(p.Endpoint) {
return NewHTTPMapRemoveProxy(p)
}
return NewGRPCMapRemoveProxy(name, p)
}
func GetSharedPollRefreshProxy(name string, p Config) (SharedPollRefreshProxy, error) {
normalizeProxyHeaderNames(&p)
if isHttpEndpoint(p.Endpoint) {
return NewHTTPSharedPollRefreshProxy(p)
}
return NewGRPCSharedPollRefreshProxy(name, p)
}
type PerCallData struct {
Meta json.RawMessage
}

View File

@@ -0,0 +1,447 @@
package proxy
import (
"context"
"net/http"
"os"
"testing"
"github.com/centrifugal/centrifugo/v6/internal/clientcontext"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/middleware"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/metadata"
)
func TestMain(m *testing.M) {
// Initialize metrics with a custom registry for tests to avoid conflicts
registry := prometheus.NewRegistry()
_ = metrics.Init(metrics.Config{
Registerer: registry,
})
os.Exit(m.Run())
}
// TestRequestHeaders_StaticHeadersOverride tests that static headers are set
// but can be overridden by client headers only when the header key is explicitly
// allowed in the configuration.
func TestRequestHeaders_StaticHeadersOverride(t *testing.T) {
tests := []struct {
name string
staticHeaders map[string]string
clientHeaders http.Header
allowedHeaders []string
expectedResult map[string]string
}{
{
name: "static header not overridden when key not allowed",
staticHeaders: map[string]string{
"X-Static-Header": "static-value",
},
clientHeaders: http.Header{
"X-Static-Header": []string{"client-value"},
},
allowedHeaders: []string{}, // No headers allowed
expectedResult: map[string]string{
"X-Static-Header": "static-value",
"Content-Type": "application/json",
},
},
{
name: "static header overridden when key is allowed",
staticHeaders: map[string]string{
"X-Static-Header": "static-value",
},
clientHeaders: http.Header{
"X-Static-Header": []string{"client-value"},
},
allowedHeaders: []string{"x-static-header"}, // Header is allowed
expectedResult: map[string]string{
"X-Static-Header": "client-value",
"Content-Type": "application/json",
},
},
{
name: "multiple static headers with partial override",
staticHeaders: map[string]string{
"X-Static-1": "static-1",
"X-Static-2": "static-2",
},
clientHeaders: http.Header{
"X-Static-1": []string{"client-1"},
"X-Static-2": []string{"client-2"},
},
allowedHeaders: []string{"x-static-1"}, // Only first header is allowed
expectedResult: map[string]string{
"X-Static-1": "client-1", // Overridden (allowed)
"X-Static-2": "static-2", // Not overridden (not allowed)
"Content-Type": "application/json",
},
},
{
name: "client header not in static set but allowed",
staticHeaders: map[string]string{
"X-Static-Header": "static-value",
},
clientHeaders: http.Header{
"X-Client-Only": []string{"client-only-value"},
},
allowedHeaders: []string{"x-client-only"},
expectedResult: map[string]string{
"X-Static-Header": "static-value",
"X-Client-Only": "client-only-value",
"Content-Type": "application/json",
},
},
{
name: "no static headers",
staticHeaders: map[string]string{},
clientHeaders: http.Header{
"X-Client-Header": []string{"client-value"},
},
allowedHeaders: []string{"x-client-header"},
expectedResult: map[string]string{
"X-Client-Header": "client-value",
"Content-Type": "application/json",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
ctx = middleware.SetHeadersToContext(ctx, tt.clientHeaders)
result := requestHeaders(ctx, tt.allowedHeaders, []string{}, []string{}, tt.staticHeaders)
require.Equal(t, len(tt.expectedResult), len(result), "number of headers should match")
for expectedKey, expectedValue := range tt.expectedResult {
require.Equal(t, expectedValue, result.Get(expectedKey),
"header %s should have value %s", expectedKey, expectedValue)
}
})
}
}
func TestDroppedEmulatedHeaderNames(t *testing.T) {
emulated := map[string]string{
"X-User-Id": "forged", // in http_headers only -> dropped (migration hint)
"X-App-Token": "ok", // in emulated_headers -> forwarded, not dropped
"X-Other": "x", // in neither list -> not dropped (never forwarded)
}
dropped := droppedEmulatedHeaderNames(emulated, []string{"x-user-id"}, []string{"x-app-token"})
require.Equal(t, []string{"x-user-id"}, dropped)
// Nothing dropped when the name is also in emulated_headers (dual-source).
dropped = droppedEmulatedHeaderNames(emulated, []string{"x-user-id"}, []string{"x-app-token", "x-user-id"})
require.Empty(t, dropped)
// Nothing dropped when http_headers is empty.
dropped = droppedEmulatedHeaderNames(emulated, nil, []string{"x-app-token"})
require.Empty(t, dropped)
}
func TestEmulatedHeaderAllowList(t *testing.T) {
var p Config
p.HttpHeaders = []string{"x-user-id"}
p.EmulatedHeaders = []string{"x-app-token"}
// Only emulated_headers are eligible for emulation; http_headers never are.
require.Equal(t, []string{"x-app-token"}, emulatedHeaderAllowList(p))
}
// TestRequestHeaders_EmulatedVsTransport verifies that client-supplied emulated
// headers (from the connect frame) are forwarded only via emulated_headers,
// never via the transport-level http_headers allow list, and that a real transport
// header overrides an emulated one when a name is listed in both.
func TestRequestHeaders_EmulatedVsTransport(t *testing.T) {
tests := []struct {
name string
emulatedHeaders map[string]string
transportHeaders http.Header
allowedHeaders []string
allowedEmulatedHeaders []string
expectedResult map[string]string
}{
{
name: "emulated header is NOT forwarded via http_headers",
emulatedHeaders: map[string]string{"X-User-Id": "spoofed"},
allowedHeaders: []string{"x-user-id"},
expectedResult: map[string]string{
"Content-Type": "application/json",
},
},
{
name: "emulated header forwarded via emulated_headers",
emulatedHeaders: map[string]string{"X-User-Id": "from-client"},
allowedEmulatedHeaders: []string{"x-user-id"},
expectedResult: map[string]string{
"X-User-Id": "from-client",
"Content-Type": "application/json",
},
},
{
name: "transport header overrides emulated when listed in both",
emulatedHeaders: map[string]string{"X-User-Id": "spoofed"},
transportHeaders: http.Header{"X-User-Id": []string{"trusted"}},
allowedHeaders: []string{"x-user-id"},
allowedEmulatedHeaders: []string{"x-user-id"},
expectedResult: map[string]string{
"X-User-Id": "trusted",
"Content-Type": "application/json",
},
},
{
name: "transport header forwarded via http_headers",
transportHeaders: http.Header{"X-Real-Ip": []string{"1.2.3.4"}},
allowedHeaders: []string{"x-real-ip"},
expectedResult: map[string]string{
"X-Real-Ip": "1.2.3.4",
"Content-Type": "application/json",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
if tt.transportHeaders != nil {
ctx = middleware.SetHeadersToContext(ctx, tt.transportHeaders)
}
if tt.emulatedHeaders != nil {
ctx = clientcontext.SetEmulatedHeadersToContext(ctx, tt.emulatedHeaders)
}
result := requestHeaders(ctx, tt.allowedHeaders, []string{}, tt.allowedEmulatedHeaders, map[string]string{})
require.Equal(t, len(tt.expectedResult), len(result), "number of headers should match")
for expectedKey, expectedValue := range tt.expectedResult {
require.Equal(t, expectedValue, result.Get(expectedKey),
"header %s should have value %s", expectedKey, expectedValue)
}
})
}
}
// TestRequestHeaders_GrpcMetadataOverridesEmulated covers a client on a gRPC
// transport with an HTTP proxy backend, with the same name listed in both
// emulated_headers and grpc_metadata. The transport metadata value must land on
// the same (canonical) header key as the emulated value and override it -
// previously the lowercase metadata key coexisted with the canonical emulated
// key and both values were sent as duplicate headers.
func TestRequestHeaders_GrpcMetadataOverridesEmulated(t *testing.T) {
ctx := context.Background()
ctx = clientcontext.SetEmulatedHeadersToContext(ctx, map[string]string{"x-user-id": "emulated-value"})
ctx = metadata.NewIncomingContext(ctx, metadata.MD{"x-user-id": []string{"transport-value"}})
result := requestHeaders(ctx, []string{}, []string{"x-user-id"}, []string{"x-user-id"}, nil)
require.Equal(t, []string{"transport-value"}, result["X-User-Id"], "transport metadata value must override the emulated value")
// Inspect the raw map by its non-canonical key on purpose to prove no
// duplicate lowercase key coexists with the canonical one. Indexing
// http.Header with a non-canonical key would be flagged by staticcheck
// (SA1008), so convert to the underlying map type for this probe.
_, hasLowercaseKey := map[string][]string(result)["x-user-id"]
require.False(t, hasLowercaseKey, "no duplicate lowercase header key must remain")
require.Len(t, result, 2, "only the canonical header and Content-Type expected") // X-User-Id + Content-Type
}
// TestRequestMetadata_StaticMetadataOverride tests that static metadata is set
// but can be overridden by client metadata only when the metadata key is explicitly
// allowed in the configuration.
func TestRequestMetadata_StaticMetadataOverride(t *testing.T) {
tests := []struct {
name string
staticMetadata map[string]string
clientMetadata metadata.MD
allowedMetaKeys []string
expectedResult map[string]string
}{
{
name: "static metadata not overridden when key not allowed",
staticMetadata: map[string]string{
"x-static-meta": "static-value",
},
clientMetadata: metadata.MD{
"x-static-meta": []string{"client-value"},
},
allowedMetaKeys: []string{}, // No metadata keys allowed
expectedResult: map[string]string{
"x-static-meta": "static-value",
},
},
{
name: "static metadata overridden when key is allowed",
staticMetadata: map[string]string{
"x-static-meta": "static-value",
},
clientMetadata: metadata.MD{
"x-static-meta": []string{"client-value"},
},
allowedMetaKeys: []string{"x-static-meta"}, // Metadata key is allowed
expectedResult: map[string]string{
"x-static-meta": "client-value",
},
},
{
name: "multiple static metadata with partial override",
staticMetadata: map[string]string{
"x-static-1": "static-1",
"x-static-2": "static-2",
},
clientMetadata: metadata.MD{
"x-static-1": []string{"client-1"},
"x-static-2": []string{"client-2"},
},
allowedMetaKeys: []string{"x-static-1"}, // Only first key is allowed
expectedResult: map[string]string{
"x-static-1": "client-1", // Overridden (allowed)
"x-static-2": "static-2", // Not overridden (not allowed)
},
},
{
name: "client metadata not in static set but allowed",
staticMetadata: map[string]string{
"x-static-meta": "static-value",
},
clientMetadata: metadata.MD{
"x-client-only": []string{"client-only-value"},
},
allowedMetaKeys: []string{"x-client-only"},
expectedResult: map[string]string{
"x-static-meta": "static-value",
"x-client-only": "client-only-value",
},
},
{
name: "no static metadata",
staticMetadata: map[string]string{},
clientMetadata: metadata.MD{
"x-client-meta": []string{"client-value"},
},
allowedMetaKeys: []string{"x-client-meta"},
expectedResult: map[string]string{
"x-client-meta": "client-value",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
ctx = metadata.NewIncomingContext(ctx, tt.clientMetadata)
result := requestMetadata(ctx, []string{}, tt.allowedMetaKeys, []string{}, tt.staticMetadata)
require.Equal(t, len(tt.expectedResult), len(result), "number of metadata entries should match")
for expectedKey, expectedValue := range tt.expectedResult {
values := result.Get(expectedKey)
require.Len(t, values, 1, "metadata key %s should have exactly one value", expectedKey)
require.Equal(t, expectedValue, values[0],
"metadata %s should have value %s", expectedKey, expectedValue)
}
})
}
}
// TestRequestMetadata_EmulatedVsTransport mirrors TestRequestHeaders_EmulatedVsTransport
// for the gRPC proxy path: emulated headers are forwarded only via
// emulated_headers, never via the http_headers or grpc_metadata allow lists.
func TestRequestMetadata_EmulatedVsTransport(t *testing.T) {
tests := []struct {
name string
emulatedHeaders map[string]string
transportHeaders http.Header
incomingMetadata metadata.MD
allowedHeaders []string
allowedMetaKeys []string
allowedEmulatedHeaders []string
expectedResult map[string]string
}{
{
name: "emulated header is NOT forwarded via http_headers",
emulatedHeaders: map[string]string{"X-User-Id": "spoofed"},
allowedHeaders: []string{"x-user-id"},
expectedResult: map[string]string{},
},
{
name: "emulated header is NOT forwarded via grpc_metadata",
emulatedHeaders: map[string]string{"X-User-Id": "spoofed"},
allowedMetaKeys: []string{"x-user-id"},
expectedResult: map[string]string{},
},
{
name: "emulated header forwarded via emulated_headers",
emulatedHeaders: map[string]string{"X-User-Id": "from-client"},
allowedEmulatedHeaders: []string{"x-user-id"},
expectedResult: map[string]string{
"x-user-id": "from-client",
},
},
{
name: "transport http header overrides emulated when listed in both",
emulatedHeaders: map[string]string{"X-User-Id": "spoofed"},
transportHeaders: http.Header{"X-User-Id": []string{"trusted"}},
allowedHeaders: []string{"x-user-id"},
allowedEmulatedHeaders: []string{"x-user-id"},
expectedResult: map[string]string{
"x-user-id": "trusted",
},
},
{
name: "incoming grpc metadata forwarded via grpc_metadata",
incomingMetadata: metadata.MD{"x-client-meta": []string{"meta-value"}},
allowedMetaKeys: []string{"x-client-meta"},
expectedResult: map[string]string{
"x-client-meta": "meta-value",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
if tt.transportHeaders != nil {
ctx = middleware.SetHeadersToContext(ctx, tt.transportHeaders)
}
if tt.incomingMetadata != nil {
ctx = metadata.NewIncomingContext(ctx, tt.incomingMetadata)
}
if tt.emulatedHeaders != nil {
ctx = clientcontext.SetEmulatedHeadersToContext(ctx, tt.emulatedHeaders)
}
result := requestMetadata(ctx, tt.allowedHeaders, tt.allowedMetaKeys, tt.allowedEmulatedHeaders, map[string]string{})
require.Equal(t, len(tt.expectedResult), len(result), "number of metadata entries should match")
for expectedKey, expectedValue := range tt.expectedResult {
require.Equal(t, expectedValue, result.Get(expectedKey)[0],
"metadata %s should have value %s", expectedKey, expectedValue)
}
})
}
}
// TestRequestHeaders_DualSourceHeader verifies that listing the same name in
// both http_headers and emulated_headers is valid (the browser-emulation
// + native-client case for a backend-validated header like Authorization): the
// transport value is preferred and the emulated value is used as a fallback.
func TestRequestHeaders_DualSourceHeader(t *testing.T) {
const allowed = "authorization"
// Browser client: no transport header, emulated value is forwarded.
ctx := clientcontext.SetEmulatedHeadersToContext(context.Background(),
map[string]string{"Authorization": "Bearer from-emulation"})
result := requestHeaders(ctx, []string{allowed}, []string{}, []string{allowed}, map[string]string{})
require.Equal(t, "Bearer from-emulation", result.Get("Authorization"))
// Native client: real transport header wins over the emulated one.
ctx = middleware.SetHeadersToContext(context.Background(),
http.Header{"Authorization": []string{"Bearer from-transport"}})
ctx = clientcontext.SetEmulatedHeadersToContext(ctx,
map[string]string{"Authorization": "Bearer from-emulation"})
result = requestHeaders(ctx, []string{allowed}, []string{}, []string{allowed}, map[string]string{})
require.Equal(t, "Bearer from-transport", result.Get("Authorization"))
}

18
internal/proxy/publish.go Normal file
View File

@@ -0,0 +1,18 @@
package proxy
import (
"context"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// PublishProxy allows to send Publish requests.
type PublishProxy interface {
ProxyPublish(context.Context, *proxyproto.PublishRequest) (*proxyproto.PublishResponse, error)
// Protocol for metrics and logging.
Protocol() string
// UseBase64 for bytes in requests from Centrifugo to application backend.
UseBase64() bool
// IncludeMeta ...
IncludeMeta() bool
}

View File

@@ -0,0 +1,60 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"google.golang.org/grpc"
)
// GRPCPublishProxy ...
type GRPCPublishProxy struct {
config Config
client proxyproto.CentrifugoProxyClient
}
var _ PublishProxy = (*GRPCPublishProxy)(nil)
// NewGRPCPublishProxy ...
func NewGRPCPublishProxy(name string, p Config) (*GRPCPublishProxy, error) {
host, err := getGrpcHost(p.Endpoint)
if err != nil {
return nil, fmt.Errorf("error getting grpc host: %v", err)
}
dialOpts, err := getDialOpts(name, p)
if err != nil {
return nil, fmt.Errorf("error creating GRPC dial options: %v", err)
}
conn, err := grpc.NewClient(host, dialOpts...)
if err != nil {
return nil, fmt.Errorf("error connecting to GRPC proxy server: %v", err)
}
return &GRPCPublishProxy{
config: p,
client: proxyproto.NewCentrifugoProxyClient(conn),
}, nil
}
// ProxyPublish proxies Publish to application backend.
func (p *GRPCPublishProxy) ProxyPublish(ctx context.Context, req *proxyproto.PublishRequest) (*proxyproto.PublishResponse, error) {
ctx, cancel := context.WithTimeout(ctx, p.config.Timeout.ToDuration())
defer cancel()
return p.client.Publish(grpcRequestContext(ctx, p.config), req)
}
// Protocol ...
func (p *GRPCPublishProxy) Protocol() string {
return "grpc"
}
// UseBase64 ...
func (p *GRPCPublishProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *GRPCPublishProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

View File

@@ -0,0 +1,175 @@
package proxy
import (
"encoding/base64"
"time"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
"github.com/rs/zerolog/log"
)
// PublishHandlerConfig ...
type PublishHandlerConfig struct {
Proxies map[string]PublishProxy
}
// PublishHandler ...
type PublishHandler struct {
config PublishHandlerConfig
summary map[string]prometheus.Observer
histogram map[string]prometheus.Observer
errors map[string]prometheus.Counter
inflight map[string]prometheus.Gauge
}
// NewPublishHandler ...
func NewPublishHandler(c PublishHandlerConfig) *PublishHandler {
h := &PublishHandler{
config: c,
}
summary := map[string]prometheus.Observer{}
histogram := map[string]prometheus.Observer{}
errors := map[string]prometheus.Counter{}
inflight := map[string]prometheus.Gauge{}
for name, p := range c.Proxies {
summary[name] = metrics.ProxyCallDurationSummary.WithLabelValues(p.Protocol(), "publish", name)
histogram[name] = metrics.ProxyCallDurationHistogram.WithLabelValues(p.Protocol(), "publish", name)
errors[name] = metrics.ProxyCallErrorCount.WithLabelValues(p.Protocol(), "publish", name)
inflight[name] = metrics.ProxyCallInflightRequests.WithLabelValues(p.Protocol(), "publish", name)
}
h.summary = summary
h.histogram = histogram
h.errors = errors
h.inflight = inflight
return h
}
// PublishHandlerFunc ...
type PublishHandlerFunc func(Client, centrifuge.PublishEvent, configtypes.ChannelOptions, PerCallData) (centrifuge.PublishReply, error)
// Handle Publish.
func (h *PublishHandler) Handle(node *centrifuge.Node) PublishHandlerFunc {
return func(client Client, e centrifuge.PublishEvent, chOpts configtypes.ChannelOptions, pcd PerCallData) (centrifuge.PublishReply, error) {
started := time.Now()
var p PublishProxy
var summary prometheus.Observer
var histogram prometheus.Observer
var errors prometheus.Counter
proxyEnabled := chOpts.PublishProxyEnabled
proxyName := chOpts.PublishProxyName
if !proxyEnabled {
log.Info().Str("channel", e.Channel).Msg("publish proxy not enabled for a channel")
return centrifuge.PublishReply{}, centrifuge.ErrorNotAvailable
}
p = h.config.Proxies[proxyName]
summary = h.summary[proxyName]
histogram = h.histogram[proxyName]
errors = h.errors[proxyName]
inflight := h.inflight[proxyName]
inflight.Inc()
defer inflight.Dec()
req := &proxyproto.PublishRequest{
Client: client.ID(),
Protocol: string(client.Transport().Protocol()),
Transport: client.Transport().Name(),
Encoding: getEncoding(p.UseBase64()),
User: client.UserID(),
Channel: e.Channel,
}
if p.IncludeMeta() && pcd.Meta != nil {
req.Meta = proxyproto.Raw(pcd.Meta)
}
if !p.UseBase64() {
req.Data = e.Data
} else {
req.B64Data = base64.StdEncoding.EncodeToString(e.Data)
}
publishRep, err := p.ProxyPublish(client.Context(), req)
duration := time.Since(started).Seconds()
if err != nil {
select {
case <-client.Context().Done():
// Client connection already closed.
return centrifuge.PublishReply{}, centrifuge.DisconnectConnectionClosed
default:
}
summary.Observe(duration)
histogram.Observe(duration)
errors.Inc()
log.Error().Err(err).Str("client", client.ID()).Str("channel", e.Channel).Msg("error proxying publish")
return centrifuge.PublishReply{}, err
}
summary.Observe(duration)
histogram.Observe(duration)
if publishRep.Disconnect != nil {
return centrifuge.PublishReply{}, proxyproto.DisconnectFromProto(publishRep.Disconnect)
}
if publishRep.Error != nil {
return centrifuge.PublishReply{}, proxyproto.ErrorFromProto(publishRep.Error)
}
historySize := chOpts.HistorySize
historyTTL := chOpts.HistoryTTL
historyMetaTTL := chOpts.HistoryMetaTTL
data := e.Data
var tags map[string]string
var idempotencyKey string
var useDelta bool
var version uint64
var versionEpoch string
if publishRep.Result != nil {
if publishRep.Result.Data != nil {
data = publishRep.Result.Data
} else if publishRep.Result.B64Data != "" {
decodedData, err := base64.StdEncoding.DecodeString(publishRep.Result.B64Data)
if err != nil {
log.Error().Err(err).Str("client", client.ID()).Msg("error decoding base64 data")
return centrifuge.PublishReply{}, centrifuge.ErrorInternal
}
data = decodedData
}
if publishRep.Result.SkipHistory {
historySize = 0
historyTTL = 0
}
tags = publishRep.Result.Tags
idempotencyKey = publishRep.Result.IdempotencyKey
useDelta = publishRep.Result.Delta
version = publishRep.Result.Version
versionEpoch = publishRep.Result.VersionEpoch
}
// The proxy may replace the data client sent, so validate the final data
// against the channel format – same policy as for the client-supplied one.
if err := config.ValidatePublicationData(data, chOpts.PublicationDataFormat); err != nil {
log.Info().Err(err).Str("channel", e.Channel).Str("client", client.ID()).Msg("publish proxy data validation failed")
return centrifuge.PublishReply{}, centrifuge.ErrorBadRequest
}
result, err := node.Publish(
e.Channel, data,
centrifuge.WithClientInfo(e.ClientInfo),
centrifuge.WithHistory(historySize, historyTTL.ToDuration(), historyMetaTTL.ToDuration()),
centrifuge.WithTags(tags),
centrifuge.WithIdempotencyKey(idempotencyKey),
centrifuge.WithDelta(useDelta),
centrifuge.WithVersion(version, versionEpoch),
)
return centrifuge.PublishReply{Result: &result}, err
}
}

View File

@@ -0,0 +1,319 @@
package proxy
import (
"context"
"encoding/base64"
"fmt"
"log"
"net/http"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifuge"
"github.com/stretchr/testify/require"
)
type grpcPublishHandleTestCase struct {
*tools.CommonGRPCProxyTestCase
publishProxyHandler *PublishHandler
channelOpts configtypes.ChannelOptions
}
func newPublishHandleGRPCTestCase(ctx context.Context, proxyGRPCServer proxyGRPCTestServer, opts configtypes.ChannelOptions) grpcPublishHandleTestCase {
commonProxyTestCase := tools.NewCommonGRPCProxyTestCase(ctx, proxyGRPCServer)
publishProxy, err := NewGRPCPublishProxy("default", getTestGrpcProxy(commonProxyTestCase))
if err != nil {
log.Fatalln("could not create grpc publish proxy: ", err)
}
publishProxyHandler := NewPublishHandler(PublishHandlerConfig{
Proxies: map[string]PublishProxy{
"test": publishProxy,
},
})
return grpcPublishHandleTestCase{commonProxyTestCase, publishProxyHandler, opts}
}
type httpPublishHandleTestCase struct {
*tools.CommonHTTPProxyTestCase
publishProxyHandler *PublishHandler
channelOpts configtypes.ChannelOptions
}
func newPublishHandleHTTPTestCase(ctx context.Context, endpoint string, opts configtypes.ChannelOptions) httpPublishHandleTestCase {
commonProxyTestCase := tools.NewCommonHTTPProxyTestCase(ctx)
publishProxy, err := NewHTTPPublishProxy(getTestHttpProxy(commonProxyTestCase, endpoint))
if err != nil {
log.Fatalln("could not create http publish proxy: ", err)
}
publishProxyHandler := NewPublishHandler(PublishHandlerConfig{
Proxies: map[string]PublishProxy{
"test": publishProxy,
},
})
return httpPublishHandleTestCase{commonProxyTestCase, publishProxyHandler, opts}
}
type publishHandleTestCase struct {
publishProxyHandler *PublishHandler
protocol string
node *centrifuge.Node
client *centrifuge.Client
channelOpts configtypes.ChannelOptions
}
func (c publishHandleTestCase) invokeHandle() (reply centrifuge.PublishReply, err error) {
publishHandler := c.publishProxyHandler.Handle(c.node)
reply, err = publishHandler(c.client, centrifuge.PublishEvent{}, c.channelOpts, PerCallData{})
return reply, err
}
func newPublishHandleTestCases(httpTestCase httpPublishHandleTestCase, grpcTestCase grpcPublishHandleTestCase) []publishHandleTestCase {
return []publishHandleTestCase{
{
publishProxyHandler: grpcTestCase.publishProxyHandler,
node: grpcTestCase.Node,
client: grpcTestCase.Client,
channelOpts: grpcTestCase.channelOpts,
protocol: "grpc",
},
{
publishProxyHandler: httpTestCase.publishProxyHandler,
node: httpTestCase.Node,
client: httpTestCase.Client,
channelOpts: httpTestCase.channelOpts,
protocol: "http",
},
}
}
func TestHandlePublishWithResult(t *testing.T) {
customData := "test"
customDataB64 := base64.StdEncoding.EncodeToString([]byte(customData))
chOpts := configtypes.ChannelOptions{
HistoryTTL: configtypes.Duration(1 * time.Second),
HistorySize: 1,
PublishProxyEnabled: true,
PublishProxyName: "test",
}
opts := proxyGRPCTestServerOptions{
B64Data: customDataB64,
}
grpcTestCase := newPublishHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("result", opts), chOpts)
defer grpcTestCase.Teardown()
httpTestCase := newPublishHandleHTTPTestCase(context.Background(), "/publish", chOpts)
httpTestCase.Mux.HandleFunc("/publish", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(fmt.Sprintf(`{"result": {"b64data": "%s"}}`, customDataB64)))
})
defer httpTestCase.Teardown()
cases := newPublishHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NoError(t, err, c.protocol)
require.Equal(t, uint64(1), reply.Result.Offset, c.protocol)
require.NotEmpty(t, reply.Result.Epoch, c.protocol)
}
}
func TestHandlePublishWithSkipHistory(t *testing.T) {
customData := "test"
customDataB64 := base64.StdEncoding.EncodeToString([]byte(customData))
chOpts := configtypes.ChannelOptions{
HistoryTTL: configtypes.Duration(1 * time.Second),
HistorySize: 1,
PublishProxyEnabled: true,
PublishProxyName: "test",
}
opts := proxyGRPCTestServerOptions{
B64Data: customDataB64,
}
grpcTestCase := newPublishHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("skip history", opts), chOpts)
defer grpcTestCase.Teardown()
httpTestCase := newPublishHandleHTTPTestCase(context.Background(), "/publish", chOpts)
httpTestCase.Mux.HandleFunc("/publish", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(fmt.Sprintf(`{"result": {"b64data": "%s", "skip_history": true}}`, customDataB64)))
})
defer httpTestCase.Teardown()
cases := newPublishHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NoError(t, err, c.protocol)
require.Equal(t, uint64(0), reply.Result.Offset, c.protocol)
require.Empty(t, reply.Result.Epoch, c.protocol)
}
}
func TestHandlePublishWithContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
grpcTestCase := newPublishHandleGRPCTestCase(ctx, proxyGRPCTestServer{}, configtypes.ChannelOptions{
PublishProxyEnabled: true,
PublishProxyName: "test",
})
defer grpcTestCase.Teardown()
httpTestCase := newPublishHandleHTTPTestCase(ctx, "/publish", configtypes.ChannelOptions{
PublishProxyEnabled: true,
PublishProxyName: "test",
})
httpTestCase.Mux.HandleFunc("/publish", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{}`))
})
defer httpTestCase.Teardown()
cases := newPublishHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.ErrorIs(t, err, centrifuge.DisconnectConnectionClosed, c.protocol)
require.Equal(t, centrifuge.PublishReply{}, reply, c.protocol)
}
}
func TestHandlePublishWithoutProxyServerStart(t *testing.T) {
grpcTestCase := newPublishHandleGRPCTestCase(context.Background(), proxyGRPCTestServer{}, configtypes.ChannelOptions{
PublishProxyEnabled: true,
PublishProxyName: "test",
})
grpcTestCase.Teardown()
httpTestCase := newPublishHandleHTTPTestCase(context.Background(), "/publish", configtypes.ChannelOptions{
PublishProxyEnabled: true,
PublishProxyName: "test",
})
httpTestCase.Teardown()
cases := newPublishHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.Error(t, err, c.protocol)
require.Equal(t, centrifuge.PublishReply{}, reply, c.protocol)
}
}
func TestHandlePublishWithProxyServerCustomDisconnect(t *testing.T) {
chOpts := configtypes.ChannelOptions{
PublishProxyEnabled: true,
PublishProxyName: "test",
}
grpcTestCase := newPublishHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("custom disconnect", proxyGRPCTestServerOptions{}), chOpts)
defer grpcTestCase.Teardown()
httpTestCase := newPublishHandleHTTPTestCase(context.Background(), "/publish", chOpts)
httpTestCase.Mux.HandleFunc("/publish", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"disconnect": {"code": 4000, "reconnect": false, "reason": "custom disconnect"}}`))
})
defer httpTestCase.Teardown()
expectedErr := centrifuge.Disconnect{
Code: 4000,
Reason: "custom disconnect",
}
cases := newPublishHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NotNil(t, err, c.protocol)
require.Equal(t, expectedErr.Error(), err.Error(), c.protocol)
require.Equal(t, centrifuge.PublishReply{}, reply, c.protocol)
}
}
func TestHandlePublishWithProxyServerCustomError(t *testing.T) {
chOpts := configtypes.ChannelOptions{
PublishProxyEnabled: true,
PublishProxyName: "test",
}
grpcTestCase := newPublishHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("custom error", proxyGRPCTestServerOptions{}), chOpts)
defer grpcTestCase.Teardown()
httpTestCase := newPublishHandleHTTPTestCase(context.Background(), "/publish", chOpts)
httpTestCase.Mux.HandleFunc("/publish", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"error": {"code": 1000, "message": "custom error"}}`))
})
defer httpTestCase.Teardown()
expectedErr := centrifuge.Error{
Code: 1000,
Message: "custom error",
}
cases := newPublishHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NotNil(t, err, c.protocol)
require.Equal(t, expectedErr.Error(), err.Error(), c.protocol)
require.Equal(t, centrifuge.PublishReply{}, reply, c.protocol)
}
}
func TestHandlePublishWithInvalidCustomData(t *testing.T) {
chOpts := configtypes.ChannelOptions{
PublishProxyEnabled: true,
PublishProxyName: "test",
}
opts := proxyGRPCTestServerOptions{
B64Data: "invalid data",
}
grpcTestCase := newPublishHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("result", opts), chOpts)
defer grpcTestCase.Teardown()
httpTestCase := newPublishHandleHTTPTestCase(context.Background(), "/publish", chOpts)
httpTestCase.Mux.HandleFunc("/publish", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"result": {"b64data": "invalid data"}}`))
})
defer httpTestCase.Teardown()
cases := newPublishHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.ErrorIs(t, centrifuge.ErrorInternal, err, c.protocol)
require.Equal(t, centrifuge.PublishReply{}, reply, c.protocol)
}
}
// TestHandlePublishProxyResultDataValidated ensures data returned by a publish
// proxy is validated against the channel publication_data_format — the proxy can
// replace what the client sent, so the client-side check is not enough.
func TestHandlePublishProxyResultDataValidated(t *testing.T) {
// Not a JSON object, while the channel requires one.
badDataB64 := base64.StdEncoding.EncodeToString([]byte(`"just a string"`))
chOpts := configtypes.ChannelOptions{
PublishProxyEnabled: true,
PublishProxyName: "test",
PublicationDataFormat: configtypes.PublicationDataFormatJSONObject,
}
opts := proxyGRPCTestServerOptions{
B64Data: badDataB64,
}
grpcTestCase := newPublishHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("result", opts), chOpts)
defer grpcTestCase.Teardown()
httpTestCase := newPublishHandleHTTPTestCase(context.Background(), "/publish", chOpts)
httpTestCase.Mux.HandleFunc("/publish", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(fmt.Sprintf(`{"result": {"b64data": "%s"}}`, badDataB64)))
})
defer httpTestCase.Teardown()
cases := newPublishHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
_, err := c.invokeHandle()
require.Equal(t, centrifuge.ErrorBadRequest, err, c.protocol)
}
}

View File

@@ -0,0 +1,69 @@
package proxy
import (
"context"
"encoding/json"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// PublishRequestHTTP ...
type PublishRequestHTTP struct {
baseRequestHTTP
UserID string `json:"user"`
Channel string `json:"channel"`
Data json.RawMessage `json:"data,omitempty"`
// Base64Data to proxy binary data.
Base64Data string `json:"b64data,omitempty"`
}
// HTTPPublishProxy ...
type HTTPPublishProxy struct {
config Config
httpCaller HTTPCaller
}
var _ PublishProxy = (*HTTPPublishProxy)(nil)
// NewHTTPPublishProxy ...
func NewHTTPPublishProxy(p Config) (*HTTPPublishProxy, error) {
httpClient, err := proxyHTTPClient(p, "publish_proxy")
if err != nil {
return nil, fmt.Errorf("error creating HTTP client: %w", err)
}
return &HTTPPublishProxy{
httpCaller: NewHTTPCaller(httpClient),
config: p,
}, nil
}
// ProxyPublish proxies Publish to application backend.
func (p *HTTPPublishProxy) ProxyPublish(ctx context.Context, req *proxyproto.PublishRequest) (*proxyproto.PublishResponse, error) {
data, err := httpEncoder.EncodePublishRequest(req)
if err != nil {
return nil, err
}
respData, err := p.httpCaller.CallHTTP(ctx, p.config.Endpoint, httpRequestHeaders(ctx, p.config), data)
if err != nil {
return transformPublishResponse(err, p.config.HTTP.StatusToCodeTransforms)
}
return httpDecoder.DecodePublishResponse(respData)
}
// Protocol ...
func (p *HTTPPublishProxy) Protocol() string {
return "http"
}
// UseBase64 ...
func (p *HTTPPublishProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *HTTPPublishProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

20
internal/proxy/refresh.go Normal file
View File

@@ -0,0 +1,20 @@
package proxy
import (
"context"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// RefreshProxy allows to send refresh requests.
type RefreshProxy interface {
ProxyRefresh(context.Context, *proxyproto.RefreshRequest) (*proxyproto.RefreshResponse, error)
// Name returns name of proxy.
Name() string
// Protocol for metrics and logging.
Protocol() string
// UseBase64 for bytes in requests from Centrifugo to application backend.
UseBase64() bool
// IncludeMeta ...
IncludeMeta() bool
}

View File

@@ -0,0 +1,65 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"google.golang.org/grpc"
)
// GRPCRefreshProxy ...
type GRPCRefreshProxy struct {
config Config
client proxyproto.CentrifugoProxyClient
}
var _ RefreshProxy = (*GRPCRefreshProxy)(nil)
// NewGRPCRefreshProxy ...
func NewGRPCRefreshProxy(name string, p Config) (*GRPCRefreshProxy, error) {
host, err := getGrpcHost(p.Endpoint)
if err != nil {
return nil, fmt.Errorf("error getting grpc host: %v", err)
}
dialOpts, err := getDialOpts(name, p)
if err != nil {
return nil, fmt.Errorf("error creating GRPC dial options: %v", err)
}
conn, err := grpc.NewClient(host, dialOpts...)
if err != nil {
return nil, fmt.Errorf("error connecting to GRPC proxy server: %v", err)
}
return &GRPCRefreshProxy{
config: p,
client: proxyproto.NewCentrifugoProxyClient(conn),
}, nil
}
// ProxyRefresh proxies refresh to application backend.
func (p *GRPCRefreshProxy) ProxyRefresh(ctx context.Context, req *proxyproto.RefreshRequest) (*proxyproto.RefreshResponse, error) {
ctx, cancel := context.WithTimeout(ctx, p.config.Timeout.ToDuration())
defer cancel()
return p.client.Refresh(grpcRequestContext(ctx, p.config), req)
}
// Name ...
func (p *GRPCRefreshProxy) Name() string {
return "default"
}
// Protocol ...
func (p *GRPCRefreshProxy) Protocol() string {
return "grpc"
}
// UseBase64 ...
func (p *GRPCRefreshProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *GRPCRefreshProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

View File

@@ -0,0 +1,127 @@
package proxy
import (
"encoding/base64"
"encoding/json"
"time"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
"github.com/rs/zerolog/log"
)
// RefreshHandlerConfig ...
type RefreshHandlerConfig struct {
Proxy RefreshProxy
}
// RefreshHandler ...
type RefreshHandler struct {
config RefreshHandlerConfig
summary prometheus.Observer
histogram prometheus.Observer
errors prometheus.Counter
inflight prometheus.Gauge
}
// NewRefreshHandler ...
func NewRefreshHandler(c RefreshHandlerConfig) *RefreshHandler {
return &RefreshHandler{
config: c,
summary: metrics.ProxyCallDurationSummary.WithLabelValues(c.Proxy.Protocol(), "refresh", "default"),
histogram: metrics.ProxyCallDurationHistogram.WithLabelValues(c.Proxy.Protocol(), "refresh", "default"),
errors: metrics.ProxyCallErrorCount.WithLabelValues(c.Proxy.Protocol(), "refresh", "default"),
inflight: metrics.ProxyCallInflightRequests.WithLabelValues(c.Proxy.Protocol(), "refresh", "default"),
}
}
type RefreshExtra struct {
Meta json.RawMessage
}
// RefreshHandlerFunc ...
type RefreshHandlerFunc func(Client, centrifuge.RefreshEvent, PerCallData) (centrifuge.RefreshReply, RefreshExtra, error)
// Handle refresh.
func (h *RefreshHandler) Handle() RefreshHandlerFunc {
return func(client Client, e centrifuge.RefreshEvent, pcd PerCallData) (centrifuge.RefreshReply, RefreshExtra, error) {
started := time.Now()
h.inflight.Inc()
defer h.inflight.Dec()
req := &proxyproto.RefreshRequest{
Client: client.ID(),
Protocol: string(client.Transport().Protocol()),
Transport: client.Transport().Name(),
Encoding: getEncoding(h.config.Proxy.UseBase64()),
User: client.UserID(),
}
if h.config.Proxy.IncludeMeta() && pcd.Meta != nil {
req.Meta = proxyproto.Raw(pcd.Meta)
}
refreshRep, err := h.config.Proxy.ProxyRefresh(client.Context(), req)
duration := time.Since(started).Seconds()
if err != nil {
select {
case <-client.Context().Done():
// Client connection already closed.
return centrifuge.RefreshReply{}, RefreshExtra{}, centrifuge.DisconnectConnectionClosed
default:
}
h.summary.Observe(duration)
h.histogram.Observe(duration)
h.errors.Inc()
log.Error().Err(err).Msg("error proxying refresh")
// In case of an error give connection one more minute to live and
// then try to check again. This way we gracefully handle temporary
// problems on application backend side.
// NOTE: this interval must be configurable maybe, but for now looks
// like a reasonable value.
return centrifuge.RefreshReply{
ExpireAt: time.Now().Unix() + 60,
}, RefreshExtra{}, nil
}
h.summary.Observe(duration)
h.histogram.Observe(duration)
result := refreshRep.Result
if result == nil {
// User will be disconnected.
log.Error().Msg("no refresh result found")
return centrifuge.RefreshReply{
Expired: true,
}, RefreshExtra{}, nil
}
if result.Expired {
return centrifuge.RefreshReply{
Expired: true,
}, RefreshExtra{}, nil
}
var info []byte
if result.B64Info != "" {
decodedInfo, err := base64.StdEncoding.DecodeString(result.B64Info)
if err != nil {
log.Error().Err(err).Str("client", client.ID()).Msg("error decoding base64 info")
return centrifuge.RefreshReply{}, RefreshExtra{}, centrifuge.ErrorInternal
}
info = decodedInfo
} else {
info = result.Info
}
extra := RefreshExtra{}
if result.Meta != nil {
extra.Meta = json.RawMessage(result.Meta)
}
return centrifuge.RefreshReply{
ExpireAt: result.ExpireAt,
Info: info,
}, extra, nil
}
}

View File

@@ -0,0 +1,221 @@
package proxy
import (
"context"
"encoding/base64"
"fmt"
"log"
"net/http"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifuge"
"github.com/stretchr/testify/require"
)
type grpcRefreshHandleTestCase struct {
*tools.CommonGRPCProxyTestCase
refreshProxyHandler *RefreshHandler
}
func newRefreshHandlerGRPCTestCase(ctx context.Context, proxyGRPCServer proxyGRPCTestServer) grpcRefreshHandleTestCase {
commonProxyTestCase := tools.NewCommonGRPCProxyTestCase(ctx, proxyGRPCServer)
refreshProxy, err := NewGRPCRefreshProxy("default", getTestGrpcProxy(commonProxyTestCase))
if err != nil {
log.Fatalln("could not create grpc refresh proxy: ", err)
}
refreshProxyHandler := NewRefreshHandler(RefreshHandlerConfig{
Proxy: refreshProxy,
})
return grpcRefreshHandleTestCase{commonProxyTestCase, refreshProxyHandler}
}
type httpRefreshHandleTestCase struct {
*tools.CommonHTTPProxyTestCase
refreshProxyHandler *RefreshHandler
}
func newRefreshHandlerHTTPTestCase(ctx context.Context, endpoint string) httpRefreshHandleTestCase {
commonProxyTestCase := tools.NewCommonHTTPProxyTestCase(ctx)
refreshProxy, err := NewHTTPRefreshProxy(getTestHttpProxy(commonProxyTestCase, endpoint))
if err != nil {
log.Fatalln("could not create http refresh proxy: ", err)
}
refreshProxyHandler := NewRefreshHandler(RefreshHandlerConfig{
Proxy: refreshProxy,
})
return httpRefreshHandleTestCase{commonProxyTestCase, refreshProxyHandler}
}
type refreshHandleTestCase struct {
refreshProxyHandler *RefreshHandler
protocol string
node *centrifuge.Node
client *centrifuge.Client
}
func (c refreshHandleTestCase) invokeHandle() (reply centrifuge.RefreshReply, err error) {
refreshHandler := c.refreshProxyHandler.Handle()
reply, _, err = refreshHandler(c.client, centrifuge.RefreshEvent{}, PerCallData{})
return reply, err
}
func newRefreshHandlerTestCases(httpTestCase httpRefreshHandleTestCase, grpcTestCase grpcRefreshHandleTestCase) []refreshHandleTestCase {
return []refreshHandleTestCase{
{
refreshProxyHandler: grpcTestCase.refreshProxyHandler,
node: grpcTestCase.Node,
client: grpcTestCase.Client,
protocol: "grpc",
},
{
refreshProxyHandler: httpTestCase.refreshProxyHandler,
node: httpTestCase.Node,
client: httpTestCase.Client,
protocol: "http",
},
}
}
func TestHandleRefreshWithCredentials(t *testing.T) {
customData := "test"
customDataB64 := base64.StdEncoding.EncodeToString([]byte(customData))
expireAt := 1565436268
opts := proxyGRPCTestServerOptions{
B64Data: customDataB64,
ExpireAt: int64(expireAt),
}
grpcTestCase := newRefreshHandlerGRPCTestCase(context.Background(), newProxyGRPCTestServer("with credentials", opts))
defer grpcTestCase.Teardown()
httpTestCase := newRefreshHandlerHTTPTestCase(context.Background(), "/refresh")
httpTestCase.Mux.HandleFunc("/refresh", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(fmt.Sprintf(`{"result": {"expire_at": %d, "b64info": "%s"}}`,
expireAt,
customDataB64,
)))
})
defer httpTestCase.Teardown()
cases := newRefreshHandlerTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NoError(t, err, c.protocol)
require.Equal(t, int64(expireAt), reply.ExpireAt, c.protocol)
require.False(t, reply.Expired, c.protocol)
require.Equal(t, customData, string(reply.Info), c.protocol)
}
}
func TestHandleRefreshWithEmptyCredentials(t *testing.T) {
opts := proxyGRPCTestServerOptions{}
grpcTestCase := newRefreshHandlerGRPCTestCase(context.Background(), newProxyGRPCTestServer("", opts))
defer grpcTestCase.Teardown()
httpTestCase := newRefreshHandlerHTTPTestCase(context.Background(), "/refresh")
httpTestCase.Mux.HandleFunc("/refresh", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{}`))
})
defer httpTestCase.Teardown()
cases := newRefreshHandlerTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NoError(t, err, c.protocol)
require.Equal(t, int64(0), reply.ExpireAt, c.protocol)
require.True(t, reply.Expired, c.protocol)
require.Equal(t, "", string(reply.Info), c.protocol)
}
}
func TestHandleRefreshWithExpired(t *testing.T) {
opts := proxyGRPCTestServerOptions{}
grpcTestCase := newRefreshHandlerGRPCTestCase(context.Background(), newProxyGRPCTestServer("expired", opts))
defer grpcTestCase.Teardown()
httpTestCase := newRefreshHandlerHTTPTestCase(context.Background(), "/refresh")
httpTestCase.Mux.HandleFunc("/refresh", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"result": {"expired": true}}`))
})
defer httpTestCase.Teardown()
cases := newRefreshHandlerTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NoError(t, err, c.protocol)
require.Equal(t, int64(0), reply.ExpireAt, c.protocol)
require.True(t, reply.Expired, c.protocol)
require.Equal(t, "", string(reply.Info), c.protocol)
}
}
func TestHandleRefreshWithContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
grpcTestCase := newRefreshHandlerGRPCTestCase(ctx, proxyGRPCTestServer{})
defer grpcTestCase.Teardown()
httpTestCase := newRefreshHandlerHTTPTestCase(ctx, "/refresh")
httpTestCase.Mux.HandleFunc("/refresh", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{}`))
})
defer httpTestCase.Teardown()
cases := newRefreshHandlerTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.ErrorIs(t, centrifuge.DisconnectConnectionClosed, err, c.protocol)
require.Equal(t, centrifuge.RefreshReply{}, reply, c.protocol)
}
}
func TestHandleRefreshWithoutProxyServerStart(t *testing.T) {
grpcTestCase := newRefreshHandlerGRPCTestCase(context.Background(), proxyGRPCTestServer{})
grpcTestCase.Teardown()
httpTestCase := newRefreshHandlerHTTPTestCase(context.Background(), "/refresh")
httpTestCase.Teardown()
cases := newRefreshHandlerTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NoError(t, err, c.protocol)
expectedReply := centrifuge.RefreshReply{
ExpireAt: time.Now().Unix() + 60,
}
require.Equal(t, expectedReply, reply, c.protocol)
}
}
func TestHandleRefreshWithInvalidCustomData(t *testing.T) {
opts := proxyGRPCTestServerOptions{
B64Data: "invalid data",
}
grpcTestCase := newRefreshHandlerGRPCTestCase(context.Background(), newProxyGRPCTestServer("with credentials", opts))
defer grpcTestCase.Teardown()
httpTestCase := newRefreshHandlerHTTPTestCase(context.Background(), "/refresh")
httpTestCase.Mux.HandleFunc("/refresh", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"result": {"b64info": "invalid data"}}`))
})
defer httpTestCase.Teardown()
cases := newRefreshHandlerTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.ErrorIs(t, centrifuge.ErrorInternal, err, c.protocol)
require.Equal(t, centrifuge.RefreshReply{}, reply, c.protocol)
}
}

View File

@@ -0,0 +1,61 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// HTTPRefreshProxy ...
type HTTPRefreshProxy struct {
config Config
httpCaller HTTPCaller
}
var _ RefreshProxy = (*HTTPRefreshProxy)(nil)
// NewHTTPRefreshProxy ...
func NewHTTPRefreshProxy(p Config) (*HTTPRefreshProxy, error) {
httpClient, err := proxyHTTPClient(p, "refresh_proxy")
if err != nil {
return nil, fmt.Errorf("error creating HTTP client: %w", err)
}
return &HTTPRefreshProxy{
config: p,
httpCaller: NewHTTPCaller(httpClient),
}, nil
}
// ProxyRefresh proxies refresh to application backend.
func (p *HTTPRefreshProxy) ProxyRefresh(ctx context.Context, req *proxyproto.RefreshRequest) (*proxyproto.RefreshResponse, error) {
data, err := httpEncoder.EncodeRefreshRequest(req)
if err != nil {
return nil, err
}
respData, err := p.httpCaller.CallHTTP(ctx, p.config.Endpoint, httpRequestHeaders(ctx, p.config), data)
if err != nil {
return transformRefreshResponse(err, p.config.HTTP.StatusToCodeTransforms)
}
return httpDecoder.DecodeRefreshResponse(respData)
}
// Name ...
func (p *HTTPRefreshProxy) Name() string {
return "default"
}
// Protocol ...
func (p *HTTPRefreshProxy) Protocol() string {
return "http"
}
// UseBase64 ...
func (p *HTTPRefreshProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *HTTPRefreshProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

18
internal/proxy/rpc.go Normal file
View File

@@ -0,0 +1,18 @@
package proxy
import (
"context"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// RPCProxy allows to proxy RPC requests to application backend.
type RPCProxy interface {
ProxyRPC(context.Context, *proxyproto.RPCRequest) (*proxyproto.RPCResponse, error)
// Protocol for metrics and logging.
Protocol() string
// UseBase64 for bytes in requests from Centrifugo to application backend.
UseBase64() bool
// IncludeMeta ...
IncludeMeta() bool
}

View File

@@ -0,0 +1,60 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"google.golang.org/grpc"
)
// GRPCRPCProxy ...
type GRPCRPCProxy struct {
config Config
client proxyproto.CentrifugoProxyClient
}
var _ RPCProxy = (*GRPCRPCProxy)(nil)
// NewGRPCRPCProxy ...
func NewGRPCRPCProxy(name string, p Config) (*GRPCRPCProxy, error) {
host, err := getGrpcHost(p.Endpoint)
if err != nil {
return nil, fmt.Errorf("error getting grpc host: %v", err)
}
dialOpts, err := getDialOpts(name, p)
if err != nil {
return nil, fmt.Errorf("error creating GRPC dial options: %v", err)
}
conn, err := grpc.NewClient(host, dialOpts...)
if err != nil {
return nil, fmt.Errorf("error connecting to GRPC proxy server: %v", err)
}
return &GRPCRPCProxy{
config: p,
client: proxyproto.NewCentrifugoProxyClient(conn),
}, nil
}
// ProxyRPC ...
func (p *GRPCRPCProxy) ProxyRPC(ctx context.Context, req *proxyproto.RPCRequest) (*proxyproto.RPCResponse, error) {
ctx, cancel := context.WithTimeout(ctx, p.config.Timeout.ToDuration())
defer cancel()
return p.client.RPC(grpcRequestContext(ctx, p.config), req)
}
// Protocol ...
func (p *GRPCRPCProxy) Protocol() string {
return "grpc"
}
// UseBase64 ...
func (p *GRPCRPCProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *GRPCRPCProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

View File

@@ -0,0 +1,149 @@
package proxy
import (
"encoding/base64"
"time"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
"github.com/rs/zerolog/log"
)
// RPCHandlerConfig ...
type RPCHandlerConfig struct {
Proxies map[string]RPCProxy
}
// RPCHandler ...
type RPCHandler struct {
config RPCHandlerConfig
summary map[string]prometheus.Observer
histogram map[string]prometheus.Observer
errors map[string]prometheus.Counter
inflight map[string]prometheus.Gauge
}
// NewRPCHandler ...
func NewRPCHandler(c RPCHandlerConfig) *RPCHandler {
h := &RPCHandler{
config: c,
}
summary := map[string]prometheus.Observer{}
histogram := map[string]prometheus.Observer{}
errors := map[string]prometheus.Counter{}
inflight := map[string]prometheus.Gauge{}
for name, p := range c.Proxies {
summary[name] = metrics.ProxyCallDurationSummary.WithLabelValues(p.Protocol(), "rpc", name)
histogram[name] = metrics.ProxyCallDurationHistogram.WithLabelValues(p.Protocol(), "rpc", name)
errors[name] = metrics.ProxyCallErrorCount.WithLabelValues(p.Protocol(), "rpc", name)
inflight[name] = metrics.ProxyCallInflightRequests.WithLabelValues(p.Protocol(), "rpc", name)
}
h.summary = summary
h.histogram = histogram
h.errors = errors
h.inflight = inflight
return h
}
// RPCHandlerFunc ...
type RPCHandlerFunc func(Client, centrifuge.RPCEvent, *config.Container, PerCallData) (centrifuge.RPCReply, error)
// Handle RPC.
func (h *RPCHandler) Handle() RPCHandlerFunc {
return func(client Client, e centrifuge.RPCEvent, cfgContainer *config.Container, pcd PerCallData) (centrifuge.RPCReply, error) {
started := time.Now()
var p RPCProxy
var summary prometheus.Observer
var histogram prometheus.Observer
var errors prometheus.Counter
rpcOpts, ok, err := cfgContainer.RpcOptions(e.Method)
if err != nil {
log.Error().Err(err).Str("method", e.Method).Msg("error getting RPC options")
return centrifuge.RPCReply{}, centrifuge.ErrorInternal
}
if !ok {
log.Info().Str("method", e.Method).Msg("rpc options not found")
return centrifuge.RPCReply{}, centrifuge.ErrorMethodNotFound
}
proxyEnabled := rpcOpts.ProxyEnabled
proxyName := rpcOpts.ProxyName
if !proxyEnabled {
log.Info().Str("method", e.Method).Msg("rpc proxy not enabled for a method")
return centrifuge.RPCReply{}, centrifuge.ErrorNotAvailable
}
p = h.config.Proxies[proxyName]
summary = h.summary[proxyName]
histogram = h.histogram[proxyName]
errors = h.errors[proxyName]
inflight := h.inflight[proxyName]
inflight.Inc()
defer inflight.Dec()
req := &proxyproto.RPCRequest{
Client: client.ID(),
Protocol: string(client.Transport().Protocol()),
Transport: client.Transport().Name(),
Encoding: getEncoding(p.UseBase64()),
User: client.UserID(),
Method: e.Method,
}
if p.IncludeMeta() && pcd.Meta != nil {
req.Meta = proxyproto.Raw(pcd.Meta)
}
if !p.UseBase64() {
req.Data = e.Data
} else {
req.B64Data = base64.StdEncoding.EncodeToString(e.Data)
}
rpcRep, err := p.ProxyRPC(client.Context(), req)
duration := time.Since(started).Seconds()
if err != nil {
select {
case <-client.Context().Done():
// Client connection already closed.
return centrifuge.RPCReply{}, centrifuge.DisconnectConnectionClosed
default:
}
summary.Observe(duration)
histogram.Observe(duration)
errors.Inc()
log.Error().Err(err).Msg("error proxying RPC")
return centrifuge.RPCReply{}, err
}
summary.Observe(duration)
histogram.Observe(duration)
if rpcRep.Disconnect != nil {
return centrifuge.RPCReply{}, proxyproto.DisconnectFromProto(rpcRep.Disconnect)
}
if rpcRep.Error != nil {
return centrifuge.RPCReply{}, proxyproto.ErrorFromProto(rpcRep.Error)
}
rpcData := rpcRep.Result
var data []byte
if rpcData != nil {
if rpcData.B64Data != "" {
decodedData, err := base64.StdEncoding.DecodeString(rpcData.B64Data)
if err != nil {
log.Error().Err(err).Str("client", client.ID()).Msg("error decoding base64 data")
return centrifuge.RPCReply{}, centrifuge.ErrorInternal
}
data = decodedData
} else {
data = rpcData.Data
}
}
return centrifuge.RPCReply{
Data: data,
}, nil
}
}

View File

@@ -0,0 +1,264 @@
package proxy
import (
"context"
"encoding/base64"
"fmt"
"log"
"net/http"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifuge"
"github.com/stretchr/testify/require"
)
type grpcRPCHandleTestCase struct {
*tools.CommonGRPCProxyTestCase
rpcProxyHandler *RPCHandler
}
func newRPCHandlerGRPCTestCase(ctx context.Context, proxyGRPCServer proxyGRPCTestServer) grpcRPCHandleTestCase {
commonProxyTestCase := tools.NewCommonGRPCProxyTestCase(ctx, proxyGRPCServer)
rpcProxy, err := NewGRPCRPCProxy("default", getTestGrpcProxy(commonProxyTestCase))
if err != nil {
log.Fatalln("could not create grpc rpc proxy: ", err)
}
rpcProxyHandler := NewRPCHandler(RPCHandlerConfig{
Proxies: map[string]RPCProxy{
"test": rpcProxy,
},
})
return grpcRPCHandleTestCase{commonProxyTestCase, rpcProxyHandler}
}
type httpRPCHandleTestCase struct {
*tools.CommonHTTPProxyTestCase
rpcProxyHandler *RPCHandler
}
func newRPCHandlerHTTPTestCase(ctx context.Context, endpoint string) httpRPCHandleTestCase {
commonProxyTestCase := tools.NewCommonHTTPProxyTestCase(ctx)
rpcProxy, err := NewHTTPRPCProxy(getTestHttpProxy(commonProxyTestCase, endpoint))
if err != nil {
log.Fatalln("could not create http rpc proxy: ", err)
}
rpcProxyHandler := NewRPCHandler(RPCHandlerConfig{
Proxies: map[string]RPCProxy{
"test": rpcProxy,
},
})
return httpRPCHandleTestCase{commonProxyTestCase, rpcProxyHandler}
}
type rpcHandleTestCase struct {
rpcProxyHandler *RPCHandler
protocol string
node *centrifuge.Node
client *centrifuge.Client
}
func (c rpcHandleTestCase) invokeHandle() (reply centrifuge.RPCReply, err error) {
rpcHandler := c.rpcProxyHandler.Handle()
cfg := config.DefaultConfig()
cfg.RPC = configtypes.RPC{
WithoutNamespace: configtypes.RpcOptions{
ProxyName: "test",
ProxyEnabled: true,
},
}
cfg.Proxies = configtypes.NamedProxies{
{
Name: "test",
Proxy: configtypes.Proxy{
Endpoint: "https://example.com/rpc",
Timeout: configtypes.Duration(time.Second),
},
},
}
cfgContainer, err := config.NewContainer(cfg)
if err != nil {
return centrifuge.RPCReply{}, err
}
reply, err = rpcHandler(c.client, centrifuge.RPCEvent{}, cfgContainer, PerCallData{})
return reply, err
}
func newRPCHandlerTestCases(httpTestCase httpRPCHandleTestCase, grpcTestCase grpcRPCHandleTestCase) []rpcHandleTestCase {
return []rpcHandleTestCase{
{
rpcProxyHandler: grpcTestCase.rpcProxyHandler,
node: grpcTestCase.Node,
client: grpcTestCase.Client,
protocol: "grpc",
},
{
rpcProxyHandler: httpTestCase.rpcProxyHandler,
node: httpTestCase.Node,
client: httpTestCase.Client,
protocol: "http",
},
}
}
func TestHandleRPCWithResult(t *testing.T) {
clientData := `{"field":"some data"}`
opts := proxyGRPCTestServerOptions{Data: []byte(clientData)}
grpcTestCase := newRPCHandlerGRPCTestCase(context.Background(), newProxyGRPCTestServer("result", opts))
defer grpcTestCase.Teardown()
httpTestCase := newRPCHandlerHTTPTestCase(context.Background(), "/rpc")
httpTestCase.Mux.HandleFunc("/rpc", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(fmt.Sprintf(`{"result": {"data": %s}}`, clientData)))
})
defer httpTestCase.Teardown()
cases := newRPCHandlerTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NoError(t, err, c.protocol)
require.Equal(t, []byte(clientData), reply.Data)
}
}
func TestHandleRPCWithContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
clientData := `{"field":"some data"}`
opts := proxyGRPCTestServerOptions{Data: []byte(clientData)}
grpcTestCase := newRPCHandlerGRPCTestCase(ctx, newProxyGRPCTestServer("result", opts))
defer grpcTestCase.Teardown()
httpTestCase := newRPCHandlerHTTPTestCase(ctx, "/rpc")
httpTestCase.Mux.HandleFunc("/rpc", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(fmt.Sprintf(`{"result": {"data": %s}}`, clientData)))
})
defer httpTestCase.Teardown()
cases := newRPCHandlerTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.ErrorIs(t, err, centrifuge.DisconnectConnectionClosed, c.protocol)
require.Equal(t, centrifuge.RPCReply{}, reply, c.protocol)
}
}
func TestHandleRPCWithoutProxyServerStart(t *testing.T) {
grpcTestCase := newRPCHandlerGRPCTestCase(context.Background(), proxyGRPCTestServer{})
grpcTestCase.Teardown()
httpTestCase := newRPCHandlerHTTPTestCase(context.Background(), "/rpc")
httpTestCase.Teardown()
cases := newRPCHandlerTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.Error(t, err, c.protocol)
require.Equal(t, centrifuge.RPCReply{}, reply)
}
}
func TestHandleRPCWithProxyServerCustomDisconnect(t *testing.T) {
grpcTestCase := newRPCHandlerGRPCTestCase(context.Background(), newProxyGRPCTestServer("custom disconnect", proxyGRPCTestServerOptions{}))
defer grpcTestCase.Teardown()
httpTestCase := newRPCHandlerHTTPTestCase(context.Background(), "/rpc")
httpTestCase.Mux.HandleFunc("/rpc", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"disconnect": {"code": 4000, "reconnect": false, "reason": "custom disconnect"}}`))
})
defer httpTestCase.Teardown()
expectedErr := centrifuge.Disconnect{
Code: 4000,
Reason: "custom disconnect",
}
cases := newRPCHandlerTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NotNil(t, err, c.protocol)
require.Equal(t, expectedErr.Error(), err.Error(), c.protocol)
require.Equal(t, centrifuge.RPCReply{}, reply, c.protocol)
}
}
func TestHandleRPCWithProxyServerCustomError(t *testing.T) {
grpcTestCase := newRPCHandlerGRPCTestCase(context.Background(), newProxyGRPCTestServer("custom error", proxyGRPCTestServerOptions{}))
defer grpcTestCase.Teardown()
httpTestCase := newRPCHandlerHTTPTestCase(context.Background(), "/rpc")
httpTestCase.Mux.HandleFunc("/rpc", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"error": {"code": 1000, "message": "custom error"}}`))
})
defer httpTestCase.Teardown()
expectedErr := centrifuge.Error{
Code: 1000,
Message: "custom error",
}
cases := newRPCHandlerTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NotNil(t, err, c.protocol)
require.Equal(t, expectedErr.Error(), err.Error(), c.protocol)
require.Equal(t, centrifuge.RPCReply{}, reply, c.protocol)
}
}
func TestHandleRPCWithValidCustomData(t *testing.T) {
customData := `test`
customDataB64 := base64.StdEncoding.EncodeToString([]byte(customData))
opts := proxyGRPCTestServerOptions{B64Data: customDataB64}
grpcTestCase := newRPCHandlerGRPCTestCase(context.Background(), newProxyGRPCTestServer("custom data", opts))
defer grpcTestCase.Teardown()
httpTestCase := newRPCHandlerHTTPTestCase(context.Background(), "/rpc")
httpTestCase.Mux.HandleFunc("/rpc", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(fmt.Sprintf(`{"result": {"b64data": "%s"}}`, customDataB64)))
})
defer httpTestCase.Teardown()
cases := newRPCHandlerTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NoError(t, err, c.protocol)
require.Equal(t, []byte(customData), reply.Data)
}
}
func TestHandleRPCWithInvalidCustomData(t *testing.T) {
opts := proxyGRPCTestServerOptions{
B64Data: "invalid data",
}
grpcTestCase := newRPCHandlerGRPCTestCase(context.Background(), newProxyGRPCTestServer("custom data", opts))
defer grpcTestCase.Teardown()
httpTestCase := newRPCHandlerHTTPTestCase(context.Background(), "/rpc")
httpTestCase.Mux.HandleFunc("/rpc", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"result": {"b64data": "invalid data"}}`))
})
defer httpTestCase.Teardown()
cases := newRPCHandlerTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.ErrorIs(t, err, centrifuge.ErrorInternal, c.protocol)
require.Equal(t, centrifuge.RPCReply{}, reply, c.protocol)
}
}

View File

@@ -0,0 +1,56 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// HTTPRPCProxy ...
type HTTPRPCProxy struct {
config Config
httpCaller HTTPCaller
}
var _ RPCProxy = (*HTTPRPCProxy)(nil)
// NewHTTPRPCProxy ...
func NewHTTPRPCProxy(p Config) (*HTTPRPCProxy, error) {
httpClient, err := proxyHTTPClient(p, "rpc_proxy")
if err != nil {
return nil, fmt.Errorf("error creating HTTP client: %w", err)
}
return &HTTPRPCProxy{
config: p,
httpCaller: NewHTTPCaller(httpClient),
}, nil
}
// ProxyRPC ...
func (p *HTTPRPCProxy) ProxyRPC(ctx context.Context, req *proxyproto.RPCRequest) (*proxyproto.RPCResponse, error) {
data, err := httpEncoder.EncodeRPCRequest(req)
if err != nil {
return nil, err
}
respData, err := p.httpCaller.CallHTTP(ctx, p.config.Endpoint, httpRequestHeaders(ctx, p.config), data)
if err != nil {
return transformRPCResponse(err, p.config.HTTP.StatusToCodeTransforms)
}
return httpDecoder.DecodeRPCResponse(respData)
}
// Protocol ...
func (p *HTTPRPCProxy) Protocol() string {
return "http"
}
// UseBase64 ...
func (p *HTTPRPCProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *HTTPRPCProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

View File

@@ -0,0 +1,14 @@
package proxy
import (
"context"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// SharedPollRefreshProxy allows to send SharedPollRefresh requests.
type SharedPollRefreshProxy interface {
ProxySharedPollRefresh(context.Context, *proxyproto.SharedPollRefreshRequest) (*proxyproto.SharedPollRefreshResponse, error)
// Protocol for metrics and logging.
Protocol() string
}

View File

@@ -0,0 +1,50 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"google.golang.org/grpc"
)
// GRPCSharedPollRefreshProxy ...
type GRPCSharedPollRefreshProxy struct {
config Config
client proxyproto.CentrifugoProxyClient
}
var _ SharedPollRefreshProxy = (*GRPCSharedPollRefreshProxy)(nil)
// NewGRPCSharedPollRefreshProxy ...
func NewGRPCSharedPollRefreshProxy(name string, p Config) (*GRPCSharedPollRefreshProxy, error) {
host, err := getGrpcHost(p.Endpoint)
if err != nil {
return nil, fmt.Errorf("error getting grpc host: %v", err)
}
dialOpts, err := getDialOpts(name, p)
if err != nil {
return nil, fmt.Errorf("error creating GRPC dial options: %v", err)
}
conn, err := grpc.NewClient(host, dialOpts...)
if err != nil {
return nil, fmt.Errorf("error connecting to GRPC proxy server: %v", err)
}
return &GRPCSharedPollRefreshProxy{
config: p,
client: proxyproto.NewCentrifugoProxyClient(conn),
}, nil
}
// ProxySharedPollRefresh proxies SharedPollRefresh to application backend.
func (p *GRPCSharedPollRefreshProxy) ProxySharedPollRefresh(ctx context.Context, req *proxyproto.SharedPollRefreshRequest) (*proxyproto.SharedPollRefreshResponse, error) {
ctx, cancel := context.WithTimeout(ctx, p.config.Timeout.ToDuration())
defer cancel()
return p.client.SharedPollRefresh(grpcRequestContext(ctx, p.config), req)
}
// Protocol ...
func (p *GRPCSharedPollRefreshProxy) Protocol() string {
return "grpc"
}

View File

@@ -0,0 +1,92 @@
package proxy
import (
"context"
"errors"
"time"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
"github.com/rs/zerolog/log"
)
// SharedPollRefreshHandlerConfig ...
type SharedPollRefreshHandlerConfig struct {
Proxy SharedPollRefreshProxy
Name string
}
// SharedPollRefreshHandler ...
type SharedPollRefreshHandler struct {
proxy SharedPollRefreshProxy
summary prometheus.Observer
histogram prometheus.Observer
errors prometheus.Counter
requestItems prometheus.Observer
responseItems prometheus.Observer
}
// NewSharedPollRefreshHandler ...
func NewSharedPollRefreshHandler(c SharedPollRefreshHandlerConfig) *SharedPollRefreshHandler {
return &SharedPollRefreshHandler{
proxy: c.Proxy,
summary: metrics.ProxyCallDurationSummary.WithLabelValues(c.Proxy.Protocol(), "shared_poll_refresh", c.Name),
histogram: metrics.ProxyCallDurationHistogram.WithLabelValues(c.Proxy.Protocol(), "shared_poll_refresh", c.Name),
errors: metrics.ProxyCallErrorCount.WithLabelValues(c.Proxy.Protocol(), "shared_poll_refresh", c.Name),
requestItems: metrics.SharedPollProxyRequestItems.WithLabelValues(c.Name),
responseItems: metrics.SharedPollProxyResponseItems.WithLabelValues(c.Name),
}
}
// Handle returns a centrifuge.SharedPollHandler.
func (h *SharedPollRefreshHandler) Handle(node *centrifuge.Node) centrifuge.SharedPollHandler {
return func(ctx context.Context, event centrifuge.SharedPollEvent) (centrifuge.SharedPollResult, error) {
started := time.Now()
protoItems := make([]*proxyproto.SharedPollRefreshItem, len(event.Items))
for i, item := range event.Items {
protoItems[i] = &proxyproto.SharedPollRefreshItem{
Key: item.Key,
Version: item.Version,
}
}
req := &proxyproto.SharedPollRefreshRequest{
Channel: event.Channel,
Items: protoItems,
}
h.requestItems.Observe(float64(len(event.Items)))
resp, err := h.proxy.ProxySharedPollRefresh(ctx, req)
duration := time.Since(started).Seconds()
h.summary.Observe(duration)
h.histogram.Observe(duration)
if err != nil {
h.errors.Inc()
log.Error().Err(err).Str("channel", event.Channel).Msg("error proxying shared poll refresh")
return centrifuge.SharedPollResult{}, err
}
if resp.Error != nil {
h.errors.Inc()
return centrifuge.SharedPollResult{}, errors.New(resp.Error.Message)
}
if resp.Result == nil {
return centrifuge.SharedPollResult{}, nil
}
h.responseItems.Observe(float64(len(resp.Result.Items)))
items := make([]centrifuge.SharedPollRefreshItem, len(resp.Result.Items))
for i, item := range resp.Result.Items {
items[i] = centrifuge.SharedPollRefreshItem{
Key: item.Key,
Data: item.Data,
PrevData: item.PrevData,
Version: item.Version,
Removed: item.Removed,
}
}
return centrifuge.SharedPollResult{Items: items, Epoch: resp.Result.Epoch}, nil
}
}

View File

@@ -0,0 +1,312 @@
package proxy
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"github.com/centrifugal/centrifuge"
"github.com/stretchr/testify/require"
)
func newTestSharedPollHTTPProxy(t *testing.T, handler http.HandlerFunc) (*SharedPollRefreshHandler, *httptest.Server) {
t.Helper()
server := httptest.NewServer(handler)
proxy, err := NewHTTPSharedPollRefreshProxy(Config{
Endpoint: server.URL,
Timeout: 5000000000, // 5s in Duration.
})
require.NoError(t, err)
h := NewSharedPollRefreshHandler(SharedPollRefreshHandlerConfig{
Proxy: proxy,
})
return h, server
}
func TestRefreshProxy_HTTP_BasicRequest(t *testing.T) {
var receivedBody []byte
handler, server := newTestSharedPollHTTPProxy(t, func(w http.ResponseWriter, r *http.Request) {
var err error
receivedBody, err = io.ReadAll(r.Body)
require.NoError(t, err)
_, _ = w.Write([]byte(`{"result": {"items": []}}`))
})
defer server.Close()
node, _ := centrifuge.New(centrifuge.Config{})
_ = node.Run()
defer func() { _ = node.Shutdown(context.Background()) }()
fn := handler.Handle(node)
_, err := fn(context.Background(), centrifuge.SharedPollEvent{
Channel: "test:channel",
Items: []centrifuge.SharedPollItem{
{Key: "key1"},
{Key: "key2"},
},
})
require.NoError(t, err)
var req proxyproto.SharedPollRefreshRequest
err = json.Unmarshal(receivedBody, &req)
require.NoError(t, err)
require.Equal(t, "test:channel", req.Channel)
require.Len(t, req.Items, 2)
require.Equal(t, "key1", req.Items[0].Key)
require.Equal(t, "key2", req.Items[1].Key)
}
func TestRefreshProxy_HTTP_ResponseParsing(t *testing.T) {
handler, server := newTestSharedPollHTTPProxy(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"result": {"items": [
{"key": "k1", "data": {"votes": 42}, "version": 5},
{"key": "k2", "data": {"votes": 10}, "version": 3}
]}}`))
})
defer server.Close()
node, _ := centrifuge.New(centrifuge.Config{})
_ = node.Run()
defer func() { _ = node.Shutdown(context.Background()) }()
fn := handler.Handle(node)
result, err := fn(context.Background(), centrifuge.SharedPollEvent{
Channel: "ch",
Items: []centrifuge.SharedPollItem{{Key: "k1"}, {Key: "k2"}},
})
require.NoError(t, err)
require.Len(t, result.Items, 2)
require.Equal(t, "k1", result.Items[0].Key)
require.Equal(t, uint64(5), result.Items[0].Version)
require.Contains(t, string(result.Items[0].Data), "42")
require.Equal(t, "k2", result.Items[1].Key)
require.Equal(t, uint64(3), result.Items[1].Version)
}
func TestRefreshProxy_HTTP_RemovedItem(t *testing.T) {
handler, server := newTestSharedPollHTTPProxy(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"result": {"items": [{"key": "k1", "removed": true}]}}`))
})
defer server.Close()
node, _ := centrifuge.New(centrifuge.Config{})
_ = node.Run()
defer func() { _ = node.Shutdown(context.Background()) }()
fn := handler.Handle(node)
result, err := fn(context.Background(), centrifuge.SharedPollEvent{
Channel: "ch",
Items: []centrifuge.SharedPollItem{{Key: "k1"}},
})
require.NoError(t, err)
require.Len(t, result.Items, 1)
require.True(t, result.Items[0].Removed)
require.Equal(t, "k1", result.Items[0].Key)
}
func TestRefreshProxy_HTTP_Timeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
}))
t.Cleanup(server.Close)
proxy, err := NewHTTPSharedPollRefreshProxy(Config{
Endpoint: server.URL,
Timeout: 100000000, // 100ms.
})
require.NoError(t, err)
h := NewSharedPollRefreshHandler(SharedPollRefreshHandlerConfig{Proxy: proxy})
node, _ := centrifuge.New(centrifuge.Config{})
_ = node.Run()
defer func() { _ = node.Shutdown(context.Background()) }()
fn := h.Handle(node)
_, err = fn(context.Background(), centrifuge.SharedPollEvent{
Channel: "ch",
Items: []centrifuge.SharedPollItem{{Key: "k1"}},
})
require.Error(t, err)
}
func TestRefreshProxy_HTTP_ErrorResponse(t *testing.T) {
handler, server := newTestSharedPollHTTPProxy(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`Internal Server Error`))
})
defer server.Close()
node, _ := centrifuge.New(centrifuge.Config{})
_ = node.Run()
defer func() { _ = node.Shutdown(context.Background()) }()
fn := handler.Handle(node)
_, err := fn(context.Background(), centrifuge.SharedPollEvent{
Channel: "ch",
Items: []centrifuge.SharedPollItem{{Key: "k1"}},
})
require.Error(t, err)
}
func TestRefreshProxy_HTTP_EmptyResponse(t *testing.T) {
handler, server := newTestSharedPollHTTPProxy(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"result": {"items": []}}`))
})
defer server.Close()
node, _ := centrifuge.New(centrifuge.Config{})
_ = node.Run()
defer func() { _ = node.Shutdown(context.Background()) }()
fn := handler.Handle(node)
result, err := fn(context.Background(), centrifuge.SharedPollEvent{
Channel: "ch",
Items: []centrifuge.SharedPollItem{{Key: "k1"}},
})
require.NoError(t, err)
require.Empty(t, result.Items)
}
func TestRefreshProxy_HTTP_DiffMode(t *testing.T) {
var receivedBody []byte
handler, server := newTestSharedPollHTTPProxy(t, func(w http.ResponseWriter, r *http.Request) {
var err error
receivedBody, err = io.ReadAll(r.Body)
require.NoError(t, err)
_, _ = w.Write([]byte(`{"result": {"items": [{"key": "k1", "data": {"v": 1}, "version": 6}]}}`))
})
defer server.Close()
node, _ := centrifuge.New(centrifuge.Config{})
_ = node.Run()
defer func() { _ = node.Shutdown(context.Background()) }()
fn := handler.Handle(node)
_, err := fn(context.Background(), centrifuge.SharedPollEvent{
Channel: "ch",
Items: []centrifuge.SharedPollItem{
{Key: "k1", Version: 5},
{Key: "k2", Version: 10},
},
})
require.NoError(t, err)
var req proxyproto.SharedPollRefreshRequest
err = json.Unmarshal(receivedBody, &req)
require.NoError(t, err)
require.Len(t, req.Items, 2)
require.Equal(t, uint64(5), req.Items[0].Version)
require.Equal(t, uint64(10), req.Items[1].Version)
}
func TestRefreshProxy_HTTP_ProxyErrorField(t *testing.T) {
handler, server := newTestSharedPollHTTPProxy(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"error": {"code": 100, "message": "custom error"}}`))
})
defer server.Close()
node, _ := centrifuge.New(centrifuge.Config{})
_ = node.Run()
defer func() { _ = node.Shutdown(context.Background()) }()
fn := handler.Handle(node)
_, err := fn(context.Background(), centrifuge.SharedPollEvent{
Channel: "ch",
Items: []centrifuge.SharedPollItem{{Key: "k1"}},
})
require.Error(t, err)
require.Contains(t, err.Error(), "custom error")
}
func TestRefreshProxy_HTTP_NullResult(t *testing.T) {
handler, server := newTestSharedPollHTTPProxy(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"result": null}`))
})
defer server.Close()
node, _ := centrifuge.New(centrifuge.Config{})
_ = node.Run()
defer func() { _ = node.Shutdown(context.Background()) }()
fn := handler.Handle(node)
result, err := fn(context.Background(), centrifuge.SharedPollEvent{
Channel: "ch",
Items: []centrifuge.SharedPollItem{{Key: "k1"}},
})
require.NoError(t, err)
require.Empty(t, result.Items)
}
func TestRefreshProxy_HTTP_LargeResponse(t *testing.T) {
handler, server := newTestSharedPollHTTPProxy(t, func(w http.ResponseWriter, r *http.Request) {
items := make([]string, 100)
for i := range items {
items[i] = fmt.Sprintf(`{"key": "k%d", "data": {"i": %d}, "version": %d}`, i, i, i+1)
}
resp := `{"result": {"items": [` + joinStrings(items) + `]}}`
_, _ = w.Write([]byte(resp))
})
defer server.Close()
node, _ := centrifuge.New(centrifuge.Config{})
_ = node.Run()
defer func() { _ = node.Shutdown(context.Background()) }()
fn := handler.Handle(node)
requestItems := make([]centrifuge.SharedPollItem, 100)
for i := range requestItems {
requestItems[i] = centrifuge.SharedPollItem{Key: fmt.Sprintf("k%d", i)}
}
result, err := fn(context.Background(), centrifuge.SharedPollEvent{
Channel: "ch",
Items: requestItems,
})
require.NoError(t, err)
require.Len(t, result.Items, 100)
require.Equal(t, "k0", result.Items[0].Key)
require.Equal(t, uint64(1), result.Items[0].Version)
require.Equal(t, "k99", result.Items[99].Key)
require.Equal(t, uint64(100), result.Items[99].Version)
}
func TestRefreshProxy_HTTP_PrevData(t *testing.T) {
handler, server := newTestSharedPollHTTPProxy(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"result": {"items": [{"key": "k1", "data": {"v": 2}, "prev_data": {"v": 1}, "version": 5}]}}`))
})
defer server.Close()
node, _ := centrifuge.New(centrifuge.Config{})
_ = node.Run()
defer func() { _ = node.Shutdown(context.Background()) }()
fn := handler.Handle(node)
result, err := fn(context.Background(), centrifuge.SharedPollEvent{
Channel: "ch",
Items: []centrifuge.SharedPollItem{{Key: "k1", Version: 4}},
})
require.NoError(t, err)
require.Len(t, result.Items, 1)
require.Equal(t, "k1", result.Items[0].Key)
require.Equal(t, uint64(5), result.Items[0].Version)
require.Contains(t, string(result.Items[0].Data), "2")
require.NotNil(t, result.Items[0].PrevData)
require.Contains(t, string(result.Items[0].PrevData), "1")
}
func joinStrings(ss []string) string {
result := ""
for i, s := range ss {
if i > 0 {
result += ", "
}
result += s
}
return result
}

View File

@@ -0,0 +1,46 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// HTTPSharedPollRefreshProxy ...
type HTTPSharedPollRefreshProxy struct {
config Config
httpCaller HTTPCaller
}
var _ SharedPollRefreshProxy = (*HTTPSharedPollRefreshProxy)(nil)
// NewHTTPSharedPollRefreshProxy ...
func NewHTTPSharedPollRefreshProxy(p Config) (*HTTPSharedPollRefreshProxy, error) {
httpClient, err := proxyHTTPClient(p, "shared_poll_refresh_proxy")
if err != nil {
return nil, fmt.Errorf("error creating HTTP client: %w", err)
}
return &HTTPSharedPollRefreshProxy{
httpCaller: NewHTTPCaller(httpClient),
config: p,
}, nil
}
// ProxySharedPollRefresh proxies SharedPollRefresh to application backend.
func (p *HTTPSharedPollRefreshProxy) ProxySharedPollRefresh(ctx context.Context, req *proxyproto.SharedPollRefreshRequest) (*proxyproto.SharedPollRefreshResponse, error) {
data, err := httpEncoder.EncodeSharedPollRefreshRequest(req)
if err != nil {
return nil, err
}
respData, err := p.httpCaller.CallHTTP(ctx, p.config.Endpoint, httpRequestHeaders(ctx, p.config), data)
if err != nil {
return nil, err
}
return httpDecoder.DecodeSharedPollRefreshResponse(respData)
}
// Protocol ...
func (p *HTTPSharedPollRefreshProxy) Protocol() string {
return "http"
}

View File

@@ -0,0 +1,18 @@
package proxy
import (
"context"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// SubRefreshProxy allows to send sub refresh requests.
type SubRefreshProxy interface {
ProxySubRefresh(context.Context, *proxyproto.SubRefreshRequest) (*proxyproto.SubRefreshResponse, error)
// Protocol for metrics and logging.
Protocol() string
// UseBase64 for bytes in requests from Centrifugo to application backend.
UseBase64() bool
// IncludeMeta ...
IncludeMeta() bool
}

View File

@@ -0,0 +1,60 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"google.golang.org/grpc"
)
// GRPCSubRefreshProxy ...
type GRPCSubRefreshProxy struct {
config Config
client proxyproto.CentrifugoProxyClient
}
var _ SubRefreshProxy = (*GRPCSubRefreshProxy)(nil)
// NewGRPCSubRefreshProxy ...
func NewGRPCSubRefreshProxy(name string, p Config) (*GRPCSubRefreshProxy, error) {
host, err := getGrpcHost(p.Endpoint)
if err != nil {
return nil, fmt.Errorf("error getting grpc host: %v", err)
}
dialOpts, err := getDialOpts(name, p)
if err != nil {
return nil, fmt.Errorf("error creating GRPC dial options: %v", err)
}
conn, err := grpc.NewClient(host, dialOpts...)
if err != nil {
return nil, fmt.Errorf("error connecting to GRPC proxy server: %v", err)
}
return &GRPCSubRefreshProxy{
config: p,
client: proxyproto.NewCentrifugoProxyClient(conn),
}, nil
}
// ProxySubRefresh proxies refresh to application backend.
func (p *GRPCSubRefreshProxy) ProxySubRefresh(ctx context.Context, req *proxyproto.SubRefreshRequest) (*proxyproto.SubRefreshResponse, error) {
ctx, cancel := context.WithTimeout(ctx, p.config.Timeout.ToDuration())
defer cancel()
return p.client.SubRefresh(grpcRequestContext(ctx, p.config), req)
}
// Protocol ...
func (p *GRPCSubRefreshProxy) Protocol() string {
return "grpc"
}
// UseBase64 ...
func (p *GRPCSubRefreshProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *GRPCSubRefreshProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

View File

@@ -0,0 +1,153 @@
package proxy
import (
"encoding/base64"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
"github.com/rs/zerolog/log"
)
// SubRefreshHandlerConfig ...
type SubRefreshHandlerConfig struct {
Proxies map[string]SubRefreshProxy
}
// SubRefreshHandler ...
type SubRefreshHandler struct {
config SubRefreshHandlerConfig
summary map[string]prometheus.Observer
histogram map[string]prometheus.Observer
errors map[string]prometheus.Counter
inflight map[string]prometheus.Gauge
}
// NewSubRefreshHandler ...
func NewSubRefreshHandler(c SubRefreshHandlerConfig) *SubRefreshHandler {
h := &SubRefreshHandler{
config: c,
}
summary := map[string]prometheus.Observer{}
histogram := map[string]prometheus.Observer{}
errors := map[string]prometheus.Counter{}
inflight := map[string]prometheus.Gauge{}
for name, p := range c.Proxies {
summary[name] = metrics.ProxyCallDurationSummary.WithLabelValues(p.Protocol(), "sub_refresh", name)
histogram[name] = metrics.ProxyCallDurationHistogram.WithLabelValues(p.Protocol(), "sub_refresh", name)
errors[name] = metrics.ProxyCallErrorCount.WithLabelValues(p.Protocol(), "sub_refresh", name)
inflight[name] = metrics.ProxyCallInflightRequests.WithLabelValues(p.Protocol(), "sub_refresh", name)
}
h.summary = summary
h.histogram = histogram
h.errors = errors
h.inflight = inflight
return h
}
type SubRefreshExtra struct {
}
// SubRefreshHandlerFunc ...
type SubRefreshHandlerFunc func(Client, centrifuge.SubRefreshEvent, configtypes.ChannelOptions, PerCallData) (centrifuge.SubRefreshReply, SubRefreshExtra, error)
// Handle refresh.
func (h *SubRefreshHandler) Handle() SubRefreshHandlerFunc {
return func(client Client, e centrifuge.SubRefreshEvent, chOpts configtypes.ChannelOptions, pcd PerCallData) (centrifuge.SubRefreshReply, SubRefreshExtra, error) {
started := time.Now()
var p SubRefreshProxy
var summary prometheus.Observer
var histogram prometheus.Observer
var errors prometheus.Counter
proxyEnabled := chOpts.SubRefreshProxyEnabled
proxyName := chOpts.SubRefreshProxyName
if !proxyEnabled {
log.Info().Str("channel", e.Channel).Msg("sub refresh proxy not configured for a channel")
return centrifuge.SubRefreshReply{}, SubRefreshExtra{}, centrifuge.ErrorNotAvailable
}
p = h.config.Proxies[proxyName]
summary = h.summary[proxyName]
histogram = h.histogram[proxyName]
errors = h.errors[proxyName]
inflight := h.inflight[proxyName]
inflight.Inc()
defer inflight.Dec()
req := &proxyproto.SubRefreshRequest{
Client: client.ID(),
Protocol: string(client.Transport().Protocol()),
Transport: client.Transport().Name(),
Encoding: getEncoding(p.UseBase64()),
User: client.UserID(),
Channel: e.Channel,
}
if p.IncludeMeta() && pcd.Meta != nil {
req.Meta = proxyproto.Raw(pcd.Meta)
}
refreshRep, err := p.ProxySubRefresh(client.Context(), req)
duration := time.Since(started).Seconds()
if err != nil {
select {
case <-client.Context().Done():
// Client connection already closed.
return centrifuge.SubRefreshReply{}, SubRefreshExtra{}, centrifuge.DisconnectConnectionClosed
default:
}
summary.Observe(duration)
histogram.Observe(duration)
errors.Inc()
log.Error().Err(err).Str("client", client.ID()).Str("channel", e.Channel).Msg("error proxying sub refresh")
// In case of an error give connection one more minute to live and
// then try to check again. This way we gracefully handle temporary
// problems on application backend side.
// NOTE: this interval must be configurable maybe, but for now looks
// like a reasonable value.
return centrifuge.SubRefreshReply{
ExpireAt: time.Now().Unix() + 60,
}, SubRefreshExtra{}, nil
}
summary.Observe(duration)
histogram.Observe(duration)
result := refreshRep.Result
if result == nil {
// Subscription will be unsubscribed.
log.Error().Msg("no sub refresh result found")
return centrifuge.SubRefreshReply{
Expired: true,
}, SubRefreshExtra{}, nil
}
if result.Expired {
return centrifuge.SubRefreshReply{
Expired: true,
}, SubRefreshExtra{}, nil
}
var info []byte
if result.B64Info != "" {
decodedInfo, err := base64.StdEncoding.DecodeString(result.B64Info)
if err != nil {
log.Error().Err(err).Str("client", client.ID()).Msg("error decoding base64 info")
return centrifuge.SubRefreshReply{}, SubRefreshExtra{}, centrifuge.ErrorInternal
}
info = decodedInfo
} else {
info = result.Info
}
extra := SubRefreshExtra{}
return centrifuge.SubRefreshReply{
ExpireAt: result.ExpireAt,
Info: info,
}, extra, nil
}
}

View File

@@ -0,0 +1,56 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// HTTPSubRefreshProxy ...
type HTTPSubRefreshProxy struct {
config Config
httpCaller HTTPCaller
}
var _ SubRefreshProxy = (*HTTPSubRefreshProxy)(nil)
// NewHTTPSubRefreshProxy ...
func NewHTTPSubRefreshProxy(p Config) (*HTTPSubRefreshProxy, error) {
httpClient, err := proxyHTTPClient(p, "sub_refresh_proxy")
if err != nil {
return nil, fmt.Errorf("error creating HTTP client: %w", err)
}
return &HTTPSubRefreshProxy{
config: p,
httpCaller: NewHTTPCaller(httpClient),
}, nil
}
// ProxySubRefresh proxies refresh to application backend.
func (p *HTTPSubRefreshProxy) ProxySubRefresh(ctx context.Context, req *proxyproto.SubRefreshRequest) (*proxyproto.SubRefreshResponse, error) {
data, err := httpEncoder.EncodeSubRefreshRequest(req)
if err != nil {
return nil, err
}
respData, err := p.httpCaller.CallHTTP(ctx, p.config.Endpoint, httpRequestHeaders(ctx, p.config), data)
if err != nil {
return transformSubRefreshResponse(err, p.config.HTTP.StatusToCodeTransforms)
}
return httpDecoder.DecodeSubRefreshResponse(respData)
}
// Protocol ...
func (p *HTTPSubRefreshProxy) Protocol() string {
return "http"
}
// UseBase64 ...
func (p *HTTPSubRefreshProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *HTTPSubRefreshProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

View File

@@ -0,0 +1,18 @@
package proxy
import (
"context"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// SubscribeProxy allows to send Subscribe requests.
type SubscribeProxy interface {
ProxySubscribe(context.Context, *proxyproto.SubscribeRequest) (*proxyproto.SubscribeResponse, error)
// Protocol for metrics and logging.
Protocol() string
// UseBase64 for bytes in requests from Centrifugo to application backend.
UseBase64() bool
// IncludeMeta ...
IncludeMeta() bool
}

View File

@@ -0,0 +1,60 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"google.golang.org/grpc"
)
// GRPCSubscribeProxy ...
type GRPCSubscribeProxy struct {
config Config
client proxyproto.CentrifugoProxyClient
}
var _ SubscribeProxy = (*GRPCSubscribeProxy)(nil)
// NewGRPCSubscribeProxy ...
func NewGRPCSubscribeProxy(name string, p Config) (*GRPCSubscribeProxy, error) {
host, err := getGrpcHost(p.Endpoint)
if err != nil {
return nil, fmt.Errorf("error getting grpc host: %v", err)
}
dialOpts, err := getDialOpts(name, p)
if err != nil {
return nil, fmt.Errorf("error creating GRPC dial options: %v", err)
}
conn, err := grpc.NewClient(host, dialOpts...)
if err != nil {
return nil, fmt.Errorf("error connecting to GRPC proxy server: %v", err)
}
return &GRPCSubscribeProxy{
config: p,
client: proxyproto.NewCentrifugoProxyClient(conn),
}, nil
}
// ProxySubscribe proxies Subscribe to application backend.
func (p *GRPCSubscribeProxy) ProxySubscribe(ctx context.Context, req *proxyproto.SubscribeRequest) (*proxyproto.SubscribeResponse, error) {
ctx, cancel := context.WithTimeout(ctx, p.config.Timeout.ToDuration())
defer cancel()
return p.client.Subscribe(grpcRequestContext(ctx, p.config), req)
}
// Protocol ...
func (p *GRPCSubscribeProxy) Protocol() string {
return "grpc"
}
// UseBase64 ...
func (p *GRPCSubscribeProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *GRPCSubscribeProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

View File

@@ -0,0 +1,200 @@
package proxy
import (
"encoding/base64"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"github.com/centrifugal/centrifugo/v6/internal/subsource"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
"github.com/rs/zerolog/log"
)
// SubscribeHandlerConfig ...
type SubscribeHandlerConfig struct {
Proxies map[string]SubscribeProxy
}
// SubscribeHandler ...
type SubscribeHandler struct {
config SubscribeHandlerConfig
summary map[string]prometheus.Observer
histogram map[string]prometheus.Observer
errors map[string]prometheus.Counter
inflight map[string]prometheus.Gauge
}
// NewSubscribeHandler ...
func NewSubscribeHandler(c SubscribeHandlerConfig) *SubscribeHandler {
h := &SubscribeHandler{
config: c,
}
summary := map[string]prometheus.Observer{}
histogram := map[string]prometheus.Observer{}
errors := map[string]prometheus.Counter{}
inflight := map[string]prometheus.Gauge{}
for name, p := range c.Proxies {
summary[name] = metrics.ProxyCallDurationSummary.WithLabelValues(p.Protocol(), "subscribe", name)
histogram[name] = metrics.ProxyCallDurationHistogram.WithLabelValues(p.Protocol(), "subscribe", name)
errors[name] = metrics.ProxyCallErrorCount.WithLabelValues(p.Protocol(), "subscribe", name)
inflight[name] = metrics.ProxyCallInflightRequests.WithLabelValues(p.Protocol(), "subscribe", name)
}
h.summary = summary
h.histogram = histogram
h.errors = errors
h.inflight = inflight
return h
}
type SubscribeExtra struct {
}
// SubscribeHandlerFunc ...
type SubscribeHandlerFunc func(Client, centrifuge.SubscribeEvent, configtypes.ChannelOptions, PerCallData) (centrifuge.SubscribeReply, SubscribeExtra, error)
// Handle Subscribe.
func (h *SubscribeHandler) Handle() SubscribeHandlerFunc {
return func(client Client, e centrifuge.SubscribeEvent, chOpts configtypes.ChannelOptions, pcd PerCallData) (centrifuge.SubscribeReply, SubscribeExtra, error) {
started := time.Now()
var p SubscribeProxy
var summary prometheus.Observer
var histogram prometheus.Observer
var errors prometheus.Counter
proxyEnabled := chOpts.SubscribeProxyEnabled
proxyName := chOpts.SubscribeProxyName
if !proxyEnabled {
log.Info().Str("channel", e.Channel).Msg("subscribe proxy not enabled for a channel")
return centrifuge.SubscribeReply{}, SubscribeExtra{}, centrifuge.ErrorNotAvailable
}
p = h.config.Proxies[proxyName]
summary = h.summary[proxyName]
histogram = h.histogram[proxyName]
errors = h.errors[proxyName]
inflight := h.inflight[proxyName]
inflight.Inc()
defer inflight.Dec()
req := &proxyproto.SubscribeRequest{
Client: client.ID(),
Protocol: string(client.Transport().Protocol()),
Transport: client.Transport().Name(),
Encoding: getEncoding(p.UseBase64()),
User: client.UserID(),
Channel: e.Channel,
Token: e.Token,
}
if !p.UseBase64() {
req.Data = e.Data
} else {
req.B64Data = base64.StdEncoding.EncodeToString(e.Data)
}
if p.IncludeMeta() && pcd.Meta != nil {
req.Meta = proxyproto.Raw(pcd.Meta)
}
subscribeRep, err := p.ProxySubscribe(client.Context(), req)
duration := time.Since(started).Seconds()
if err != nil {
select {
case <-client.Context().Done():
// Client connection already closed.
return centrifuge.SubscribeReply{}, SubscribeExtra{}, centrifuge.DisconnectConnectionClosed
default:
}
summary.Observe(duration)
histogram.Observe(duration)
errors.Inc()
log.Error().Err(err).Str("client", client.ID()).Str("channel", e.Channel).Msg("error proxying subscribe")
return centrifuge.SubscribeReply{}, SubscribeExtra{}, err
}
summary.Observe(duration)
histogram.Observe(duration)
if subscribeRep.Disconnect != nil {
return centrifuge.SubscribeReply{}, SubscribeExtra{}, proxyproto.DisconnectFromProto(subscribeRep.Disconnect)
}
if subscribeRep.Error != nil {
return centrifuge.SubscribeReply{}, SubscribeExtra{}, proxyproto.ErrorFromProto(subscribeRep.Error)
}
presence := chOpts.Presence
joinLeave := chOpts.JoinLeave
pushJoinLeave := chOpts.ForcePushJoinLeave
recovery := chOpts.ForceRecovery
positioning := chOpts.ForcePositioning
recoveryMode := chOpts.GetRecoveryMode()
var info []byte
var data []byte
var expireAt int64
var extra SubscribeExtra
if subscribeRep.Result != nil {
if subscribeRep.Result.B64Info != "" {
decodedInfo, err := base64.StdEncoding.DecodeString(subscribeRep.Result.B64Info)
if err != nil {
log.Error().Err(err).Str("client", client.ID()).Msg("error decoding base64 info")
return centrifuge.SubscribeReply{}, SubscribeExtra{}, centrifuge.ErrorInternal
}
info = decodedInfo
} else {
info = subscribeRep.Result.Info
}
if subscribeRep.Result.B64Data != "" {
decodedData, err := base64.StdEncoding.DecodeString(subscribeRep.Result.B64Data)
if err != nil {
log.Error().Err(err).Str("client", client.ID()).Msg("error decoding base64 data")
return centrifuge.SubscribeReply{}, SubscribeExtra{}, centrifuge.ErrorInternal
}
data = decodedData
} else {
data = subscribeRep.Result.Data
}
result := subscribeRep.Result
if result.Override != nil && result.Override.Presence != nil {
presence = result.Override.Presence.Value
}
if result.Override != nil && result.Override.JoinLeave != nil {
joinLeave = result.Override.JoinLeave.Value
}
if result.Override != nil && result.Override.ForcePushJoinLeave != nil {
pushJoinLeave = result.Override.ForcePushJoinLeave.Value
}
if result.Override != nil && result.Override.ForceRecovery != nil {
recovery = result.Override.ForceRecovery.Value
}
if result.Override != nil && result.Override.ForcePositioning != nil {
positioning = result.Override.ForcePositioning.Value
}
expireAt = result.ExpireAt
}
return centrifuge.SubscribeReply{
Options: centrifuge.SubscribeOptions{
ExpireAt: expireAt,
ChannelInfo: info,
EmitPresence: presence,
EmitJoinLeave: joinLeave,
PushJoinLeave: pushJoinLeave,
EnableRecovery: recovery,
EnablePositioning: positioning,
RecoveryMode: recoveryMode,
AutoCacheRecover: recovery && chOpts.AutoCacheRecover && recoveryMode == centrifuge.RecoveryModeCache,
AllowedDeltaTypes: chOpts.AllowedDeltaTypes,
AllowTagsFilter: chOpts.AllowTagsFilter,
Data: data,
Source: subsource.SubscribeProxy,
HistoryMetaTTL: time.Duration(chOpts.HistoryMetaTTL),
},
ClientSideRefresh: true,
}, extra, nil
}
}

View File

@@ -0,0 +1,368 @@
package proxy
import (
"context"
"encoding/base64"
"fmt"
"log"
"net/http"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/clientcontext"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/middleware"
"github.com/centrifugal/centrifugo/v6/internal/subsource"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifuge"
"github.com/stretchr/testify/require"
)
type grpcSubscribeHandleTestCase struct {
*tools.CommonGRPCProxyTestCase
subscribeProxyHandler *SubscribeHandler
channelOpts configtypes.ChannelOptions
}
func newSubscribeHandleGRPCTestCase(ctx context.Context, proxyGRPCServer proxyGRPCTestServer, opts configtypes.ChannelOptions) grpcSubscribeHandleTestCase {
commonProxyTestCase := tools.NewCommonGRPCProxyTestCase(ctx, proxyGRPCServer)
subscribeProxy, err := NewGRPCSubscribeProxy("default", getTestGrpcProxy(commonProxyTestCase))
if err != nil {
log.Fatalln("could not create grpc subscribe proxy: ", err)
}
subscribeProxyHandler := NewSubscribeHandler(SubscribeHandlerConfig{
Proxies: map[string]SubscribeProxy{
"test": subscribeProxy,
},
})
return grpcSubscribeHandleTestCase{commonProxyTestCase, subscribeProxyHandler, opts}
}
type httpSubscribeHandleTestCase struct {
*tools.CommonHTTPProxyTestCase
subscribeProxyHandler *SubscribeHandler
channelOpts configtypes.ChannelOptions
}
func newSubscribeHandleHTTPTestCase(ctx context.Context, endpoint string, opts configtypes.ChannelOptions) httpSubscribeHandleTestCase {
commonProxyTestCase := tools.NewCommonHTTPProxyTestCase(ctx)
subscribeProxy, err := NewHTTPSubscribeProxy(getTestHttpProxy(commonProxyTestCase, endpoint))
if err != nil {
log.Fatalln("could not create http subscribe proxy: ", err)
}
subscribeProxyHandler := NewSubscribeHandler(SubscribeHandlerConfig{
Proxies: map[string]SubscribeProxy{
"test": subscribeProxy,
},
})
return httpSubscribeHandleTestCase{commonProxyTestCase, subscribeProxyHandler, opts}
}
type subscribeHandleTestCase struct {
subscribeProxyHandler *SubscribeHandler
protocol string
node *centrifuge.Node
client *centrifuge.Client
channelOpts configtypes.ChannelOptions
}
func (c subscribeHandleTestCase) invokeHandle() (reply centrifuge.SubscribeReply, err error) {
subscribeHandler := c.subscribeProxyHandler.Handle()
reply, _, err = subscribeHandler(c.client, centrifuge.SubscribeEvent{}, c.channelOpts, PerCallData{})
return reply, err
}
func newSubscribeHandleTestCases(httpTestCase httpSubscribeHandleTestCase, grpcTestCase grpcSubscribeHandleTestCase) []subscribeHandleTestCase {
return []subscribeHandleTestCase{
{
subscribeProxyHandler: grpcTestCase.subscribeProxyHandler,
node: grpcTestCase.Node,
client: grpcTestCase.Client,
channelOpts: grpcTestCase.channelOpts,
protocol: "grpc",
},
{
subscribeProxyHandler: httpTestCase.subscribeProxyHandler,
node: httpTestCase.Node,
client: httpTestCase.Client,
channelOpts: httpTestCase.channelOpts,
protocol: "http",
},
}
}
// TestHandleSubscribeForwardsHeaders is an end-to-end test that the split header
// model works for a channel (subscribe) proxy, not just connect: the emulated and
// real transport headers captured at connect (here placed in the client context,
// exactly as OnClientConnecting sets them) are forwarded to the subscribe backend
// filtered by the subscribe proxy's own http_headers / emulated_headers lists.
func TestHandleSubscribeForwardsHeaders(t *testing.T) {
// Client context as it would look after connect: a real transport header, plus
// an emulated map that includes a legit token and a forged value for a name that
// is only in http_headers (must not win via emulation).
ctx := middleware.SetHeadersToContext(context.Background(), http.Header{"X-Real-Ip": []string{"1.2.3.4"}})
ctx = clientcontext.SetEmulatedHeadersToContext(ctx, map[string]string{
"x-app-token": "from-client",
"x-real-ip": "forged-by-client",
})
commonProxyTestCase := tools.NewCommonHTTPProxyTestCase(ctx)
defer commonProxyTestCase.Teardown()
var received http.Header
commonProxyTestCase.Mux.HandleFunc("/subscribe", func(w http.ResponseWriter, req *http.Request) {
received = req.Header.Clone()
_, _ = w.Write([]byte(`{"result": {}}`))
})
proxyCfg := configtypes.Proxy{
Endpoint: commonProxyTestCase.Server.URL + "/subscribe",
Timeout: configtypes.Duration(5 * time.Second),
ProxyCommon: configtypes.ProxyCommon{
HttpHeaders: []string{"X-Real-Ip"}, // transport-only
EmulatedHeaders: []string{"X-App-Token"}, // client-supplied
},
}
subscribeProxy, err := GetSubscribeProxy("test", proxyCfg)
require.NoError(t, err)
handler := NewSubscribeHandler(SubscribeHandlerConfig{
Proxies: map[string]SubscribeProxy{"test": subscribeProxy},
}).Handle()
chOpts := configtypes.ChannelOptions{
SubscribeProxyEnabled: true,
SubscribeProxyName: "test",
}
_, _, err = handler(commonProxyTestCase.Client, centrifuge.SubscribeEvent{}, chOpts, PerCallData{})
require.NoError(t, err)
require.NotNil(t, received, "subscribe backend should have been called")
require.Equal(t, "from-client", received.Get("X-App-Token"), "emulated header must be forwarded via the subscribe proxy's emulated_headers")
require.Equal(t, "1.2.3.4", received.Get("X-Real-Ip"), "real transport value must win; forged emulated value must NOT be forwarded via http_headers")
}
func TestHandleSubscribeWithResult(t *testing.T) {
customData := "test"
customDataB64 := base64.StdEncoding.EncodeToString([]byte(customData))
chOpts := configtypes.ChannelOptions{
Presence: true,
JoinLeave: true,
ForcePushJoinLeave: true,
ForceRecovery: true,
ForcePositioning: true,
SubscribeProxyEnabled: true,
SubscribeProxyName: "test",
}
opts := proxyGRPCTestServerOptions{
B64Data: customDataB64,
}
grpcTestCase := newSubscribeHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("result", opts), chOpts)
defer grpcTestCase.Teardown()
httpTestCase := newSubscribeHandleHTTPTestCase(context.Background(), "/subscribe", chOpts)
httpTestCase.Mux.HandleFunc("/subscribe", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(fmt.Sprintf(`{"result": {"b64info": "%s", "b64data": "%s"}}`, customDataB64, customDataB64)))
})
defer httpTestCase.Teardown()
expectedSubscribeOpts := centrifuge.SubscribeOptions{
ChannelInfo: []byte(customData),
Data: []byte(customData),
EmitPresence: true,
EmitJoinLeave: true,
PushJoinLeave: true,
EnableRecovery: true,
EnablePositioning: true,
Source: subsource.SubscribeProxy,
}
cases := newSubscribeHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NoError(t, err, c.protocol)
require.Equal(t, expectedSubscribeOpts, reply.Options, c.protocol)
require.True(t, reply.ClientSideRefresh, c.protocol)
}
}
func TestHandleSubscribeWithOverride(t *testing.T) {
customData := "test"
customDataB64 := base64.StdEncoding.EncodeToString([]byte(customData))
chOpts := configtypes.ChannelOptions{
Presence: false,
JoinLeave: true,
ForceRecovery: false,
ForcePositioning: false,
SubscribeProxyEnabled: true,
SubscribeProxyName: "test",
}
opts := proxyGRPCTestServerOptions{
B64Data: customDataB64,
}
grpcTestCase := newSubscribeHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("override", opts), chOpts)
defer grpcTestCase.Teardown()
httpTestCase := newSubscribeHandleHTTPTestCase(context.Background(), "/subscribe", chOpts)
httpTestCase.Mux.HandleFunc("/subscribe", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(fmt.Sprintf(`{"result": {"b64info": "%s", "override": {"join_leave": {"value": false}, "presence": {"value": true}, "force_positioning": {"value": true}, "force_recovery": {"value": true}}}}`, customDataB64)))
})
defer httpTestCase.Teardown()
expectedSubscribeOpts := centrifuge.SubscribeOptions{
ChannelInfo: []byte(customData),
EmitPresence: true,
EmitJoinLeave: false,
PushJoinLeave: false,
EnablePositioning: true,
EnableRecovery: true,
Source: subsource.SubscribeProxy,
}
cases := newSubscribeHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NoError(t, err, c.protocol)
require.Equal(t, expectedSubscribeOpts, reply.Options, c.protocol)
require.True(t, reply.ClientSideRefresh, c.protocol)
}
}
func TestHandleSubscribeWithContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
grpcTestCase := newSubscribeHandleGRPCTestCase(ctx, proxyGRPCTestServer{}, configtypes.ChannelOptions{
SubscribeProxyEnabled: true,
SubscribeProxyName: "test",
})
defer grpcTestCase.Teardown()
httpTestCase := newSubscribeHandleHTTPTestCase(ctx, "/subscribe", configtypes.ChannelOptions{
SubscribeProxyEnabled: true,
SubscribeProxyName: "test",
})
httpTestCase.Mux.HandleFunc("/subscribe", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{}`))
})
defer httpTestCase.Teardown()
cases := newSubscribeHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.ErrorIs(t, centrifuge.DisconnectConnectionClosed, err, c.protocol)
require.Equal(t, centrifuge.SubscribeReply{}, reply, c.protocol)
}
}
func TestHandleSubscribeWithoutProxyServerStart(t *testing.T) {
grpcTestCase := newSubscribeHandleGRPCTestCase(context.Background(), proxyGRPCTestServer{}, configtypes.ChannelOptions{
SubscribeProxyEnabled: true,
SubscribeProxyName: "test",
})
grpcTestCase.Teardown()
httpTestCase := newSubscribeHandleHTTPTestCase(context.Background(), "/subscribe", configtypes.ChannelOptions{
SubscribeProxyEnabled: true,
SubscribeProxyName: "test",
})
httpTestCase.Teardown()
cases := newSubscribeHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.Error(t, err, c.protocol)
require.Equal(t, centrifuge.SubscribeReply{}, reply, c.protocol)
}
}
func TestHandleSubscribeWithProxyServerCustomDisconnect(t *testing.T) {
chOpts := configtypes.ChannelOptions{
SubscribeProxyEnabled: true,
SubscribeProxyName: "test",
}
grpcTestCase := newSubscribeHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("custom disconnect", proxyGRPCTestServerOptions{}), chOpts)
defer grpcTestCase.Teardown()
httpTestCase := newSubscribeHandleHTTPTestCase(context.Background(), "/subscribe", chOpts)
httpTestCase.Mux.HandleFunc("/subscribe", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"disconnect": {"code": 4000, "reconnect": false, "reason": "custom disconnect"}}`))
})
defer httpTestCase.Teardown()
expectedErr := centrifuge.Disconnect{
Code: 4000,
Reason: "custom disconnect",
}
cases := newSubscribeHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NotNil(t, err, c.protocol)
require.Equal(t, expectedErr.Error(), err.Error(), c.protocol)
require.Equal(t, centrifuge.SubscribeReply{}, reply, c.protocol)
}
}
func TestHandleSubscribeWithProxyServerCustomError(t *testing.T) {
chOpts := configtypes.ChannelOptions{
SubscribeProxyEnabled: true,
SubscribeProxyName: "test",
}
grpcTestCase := newSubscribeHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("custom error", proxyGRPCTestServerOptions{}), chOpts)
defer grpcTestCase.Teardown()
httpTestCase := newSubscribeHandleHTTPTestCase(context.Background(), "/subscribe", chOpts)
httpTestCase.Mux.HandleFunc("/subscribe", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"error": {"code": 1000, "message": "custom error"}}`))
})
defer httpTestCase.Teardown()
expectedErr := centrifuge.Error{
Code: 1000,
Message: "custom error",
}
cases := newSubscribeHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.NotNil(t, err, c.protocol)
require.Equal(t, expectedErr.Error(), err.Error(), c.protocol)
require.Equal(t, centrifuge.SubscribeReply{}, reply, c.protocol)
}
}
func TestHandleSubscribeWithInvalidCustomData(t *testing.T) {
chOpts := configtypes.ChannelOptions{
SubscribeProxyEnabled: true,
SubscribeProxyName: "test",
}
opts := proxyGRPCTestServerOptions{
B64Data: "invalid data",
}
grpcTestCase := newSubscribeHandleGRPCTestCase(context.Background(), newProxyGRPCTestServer("result", opts), chOpts)
defer grpcTestCase.Teardown()
httpTestCase := newSubscribeHandleHTTPTestCase(context.Background(), "/subscribe", chOpts)
httpTestCase.Mux.HandleFunc("/subscribe", func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`{"result": {"b64info": "invalid data"}}`))
})
defer httpTestCase.Teardown()
cases := newSubscribeHandleTestCases(httpTestCase, grpcTestCase)
for _, c := range cases {
reply, err := c.invokeHandle()
require.ErrorIs(t, err, centrifuge.ErrorInternal, c.protocol)
require.Equal(t, centrifuge.SubscribeReply{}, reply, c.protocol)
}
}

View File

@@ -0,0 +1,56 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
// HTTPSubscribeProxy ...
type HTTPSubscribeProxy struct {
config Config
httpCaller HTTPCaller
}
var _ SubscribeProxy = (*HTTPSubscribeProxy)(nil)
// NewHTTPSubscribeProxy ...
func NewHTTPSubscribeProxy(p Config) (*HTTPSubscribeProxy, error) {
httpClient, err := proxyHTTPClient(p, "subscribe_proxy")
if err != nil {
return nil, fmt.Errorf("error creating HTTP client: %w", err)
}
return &HTTPSubscribeProxy{
config: p,
httpCaller: NewHTTPCaller(httpClient),
}, nil
}
// ProxySubscribe proxies Subscribe to application backend.
func (p *HTTPSubscribeProxy) ProxySubscribe(ctx context.Context, req *proxyproto.SubscribeRequest) (*proxyproto.SubscribeResponse, error) {
data, err := httpEncoder.EncodeSubscribeRequest(req)
if err != nil {
return nil, err
}
respData, err := p.httpCaller.CallHTTP(ctx, p.config.Endpoint, httpRequestHeaders(ctx, p.config), data)
if err != nil {
return transformSubscribeResponse(err, p.config.HTTP.StatusToCodeTransforms)
}
return httpDecoder.DecodeSubscribeResponse(respData)
}
// Protocol ...
func (p *HTTPSubscribeProxy) Protocol() string {
return "http"
}
// UseBase64 ...
func (p *HTTPSubscribeProxy) UseBase64() bool {
return p.config.BinaryEncoding
}
// IncludeMeta ...
func (p *HTTPSubscribeProxy) IncludeMeta() bool {
return p.config.IncludeConnectionMeta
}

View File

@@ -0,0 +1,45 @@
package proxy
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"google.golang.org/grpc"
)
type SubscribeStreamProxy struct {
config Config
client proxyproto.CentrifugoProxyClient
}
func NewSubscribeStreamProxy(name string, p Config) (*SubscribeStreamProxy, error) {
host, err := getGrpcHost(p.Endpoint)
if err != nil {
return nil, fmt.Errorf("error getting grpc host: %v", err)
}
dialOpts, err := getDialOpts(name, p)
if err != nil {
return nil, fmt.Errorf("error creating GRPC dial options: %v", err)
}
conn, err := grpc.NewClient(host, dialOpts...)
if err != nil {
return nil, fmt.Errorf("error connecting to GRPC proxy server: %v", err)
}
return &SubscribeStreamProxy{
config: p,
client: proxyproto.NewCentrifugoProxyClient(conn),
}, nil
}
// SubscribeUnidirectional ...
func (p *SubscribeStreamProxy) SubscribeUnidirectional(ctx context.Context, req *proxyproto.SubscribeRequest) (proxyproto.CentrifugoProxy_SubscribeUnidirectionalClient, error) {
return p.client.SubscribeUnidirectional(grpcRequestContext(ctx, p.config), req)
}
// SubscribeBidirectional ...
func (p *SubscribeStreamProxy) SubscribeBidirectional(ctx context.Context) (proxyproto.CentrifugoProxy_SubscribeBidirectionalClient, error) {
return p.client.SubscribeBidirectional(grpcRequestContext(ctx, p.config))
}

View File

@@ -0,0 +1,354 @@
package proxy
import (
"context"
"encoding/base64"
"errors"
"io"
"sync"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
"github.com/centrifugal/centrifugo/v6/internal/subsource"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
"github.com/rs/zerolog/log"
)
// SubscribeStreamHandlerConfig ...
type SubscribeStreamHandlerConfig struct {
Proxies map[string]*SubscribeStreamProxy
}
// SubscribeStreamHandler ...
type SubscribeStreamHandler struct {
config SubscribeStreamHandlerConfig
summary map[string]prometheus.Observer
histogram map[string]prometheus.Observer
errors map[string]prometheus.Counter
inflight map[string]prometheus.Gauge
}
// NewSubscribeStreamHandler ...
func NewSubscribeStreamHandler(c SubscribeStreamHandlerConfig) *SubscribeStreamHandler {
h := &SubscribeStreamHandler{
config: c,
}
summary := map[string]prometheus.Observer{}
histogram := map[string]prometheus.Observer{}
errCounters := map[string]prometheus.Counter{}
inflight := map[string]prometheus.Gauge{}
for name := range c.Proxies {
summary[name] = metrics.ProxyCallDurationSummary.WithLabelValues("grpc", "subscribe_stream", name)
histogram[name] = metrics.ProxyCallDurationHistogram.WithLabelValues("grpc", "subscribe_stream", name)
errCounters[name] = metrics.ProxyCallErrorCount.WithLabelValues("grpc", "subscribe_stream", name)
inflight[name] = metrics.ProxyCallInflightRequests.WithLabelValues("grpc", "subscribe_stream", name)
}
h.summary = summary
h.histogram = histogram
h.errors = errCounters
h.inflight = inflight
return h
}
// StreamPublishFunc ...
type StreamPublishFunc func(data []byte) error
// SubscribeStreamHandlerFunc ...
type SubscribeStreamHandlerFunc func(
Client, bool, centrifuge.SubscribeEvent, configtypes.ChannelOptions, PerCallData,
) (centrifuge.SubscribeReply, StreamPublishFunc, func(), error)
// Handle ...
func (h *SubscribeStreamHandler) Handle() SubscribeStreamHandlerFunc {
return func(
client Client, bidi bool, e centrifuge.SubscribeEvent,
chOpts configtypes.ChannelOptions, pcd PerCallData,
) (centrifuge.SubscribeReply, StreamPublishFunc, func(), error) {
started := time.Now()
var p *SubscribeStreamProxy
var summary prometheus.Observer
var histogram prometheus.Observer
var errCounter prometheus.Counter
proxyEnabled := chOpts.SubscribeStreamProxyEnabled
proxyName := chOpts.SubscribeStreamProxyName
if !proxyEnabled {
log.Info().Str("channel", e.Channel).Msg("subscribe stream proxy not enabled for a channel")
return centrifuge.SubscribeReply{}, nil, nil, centrifuge.ErrorNotAvailable
}
p = h.config.Proxies[proxyName]
summary = h.summary[proxyName]
histogram = h.histogram[proxyName]
errCounter = h.errors[proxyName]
inflight := h.inflight[proxyName]
inflight.Inc()
defer inflight.Dec()
req := &proxyproto.SubscribeRequest{
Client: client.ID(),
Protocol: string(client.Transport().Protocol()),
Transport: client.Transport().Name(),
Encoding: getEncoding(p.config.BinaryEncoding),
User: client.UserID(),
Channel: e.Channel,
Token: e.Token,
}
if !p.config.BinaryEncoding {
req.Data = e.Data
} else {
req.B64Data = base64.StdEncoding.EncodeToString(e.Data)
}
if p.config.IncludeConnectionMeta && pcd.Meta != nil {
req.Meta = proxyproto.Raw(pcd.Meta)
}
subscriptionReady := make(chan struct{})
var subscriptionReadyOnce sync.Once
subscribeRep, publishFunc, cancelFunc, err := p.SubscribeStream(
client.Context(),
bidi,
req,
func(pub *proxyproto.Publication, err error) {
subscriptionReadyOnce.Do(func() {
select {
case <-subscriptionReady:
case <-client.Context().Done():
return
}
})
select {
case <-client.Context().Done():
return
default:
}
if err != nil {
if errors.Is(err, io.EOF) {
client.Unsubscribe(e.Channel, centrifuge.Unsubscribe{
Code: centrifuge.UnsubscribeCodeServer,
Reason: "server unsubscribe",
})
return
}
client.Unsubscribe(e.Channel, centrifuge.Unsubscribe{
Code: centrifuge.UnsubscribeCodeInsufficient,
Reason: "insufficient state",
})
return
}
_ = client.WritePublication(e.Channel, &centrifuge.Publication{
Data: pub.Data,
Tags: pub.Tags,
}, centrifuge.StreamPosition{})
},
)
duration := time.Since(started).Seconds()
if err != nil {
select {
case <-client.Context().Done():
// Client connection already closed.
return centrifuge.SubscribeReply{}, nil, nil, centrifuge.DisconnectConnectionClosed
default:
}
summary.Observe(duration)
histogram.Observe(duration)
errCounter.Inc()
log.Error().Err(err).Str("client", client.ID()).Str("channel", e.Channel).Msg("error from subscribe stream proxy")
//proxyCallErrorCount.WithLabelValues(proxyName, "subscribe", "internal").Inc()
return centrifuge.SubscribeReply{}, nil, nil, err
}
summary.Observe(duration)
histogram.Observe(duration)
if subscribeRep.Disconnect != nil {
//proxyCallErrorCount.WithLabelValues(proxyName, "subscribe", "disconnect_"+strconv.FormatUint(uint64(subscribeRep.Disconnect.Code), 10)).Inc()
// SubscribeStream returned successfully here, so it has opened the stream
// and spawned its reader goroutine. Every rejection/error return below
// must cancel it or the stream context and goroutine leak until the
// client disconnects.
cancelFunc()
return centrifuge.SubscribeReply{}, nil, nil, &centrifuge.Disconnect{
Code: subscribeRep.Disconnect.Code,
Reason: subscribeRep.Disconnect.Reason,
}
}
if subscribeRep.Error != nil {
//proxyCallErrorCount.WithLabelValues(proxyName, "subscribe", "error_"+strconv.FormatUint(uint64(subscribeRep.Error.Code), 10)).Inc()
cancelFunc()
return centrifuge.SubscribeReply{}, nil, nil, &centrifuge.Error{
Code: subscribeRep.Error.Code,
Message: subscribeRep.Error.Message,
}
}
presence := chOpts.Presence
joinLeave := chOpts.JoinLeave
pushJoinLeave := chOpts.ForcePushJoinLeave
var info []byte
var data []byte
var expireAt int64
if subscribeRep.Result != nil {
if subscribeRep.Result.B64Info != "" {
decodedInfo, err := base64.StdEncoding.DecodeString(subscribeRep.Result.B64Info)
if err != nil {
log.Error().Err(err).Str("client", client.ID()).Msg("error decoding base64 info")
cancelFunc()
return centrifuge.SubscribeReply{}, nil, nil, centrifuge.ErrorInternal
}
info = decodedInfo
} else {
info = subscribeRep.Result.Info
}
if subscribeRep.Result.B64Data != "" {
decodedData, err := base64.StdEncoding.DecodeString(subscribeRep.Result.B64Data)
if err != nil {
log.Error().Err(err).Str("client", client.ID()).Msg("error decoding base64 data")
cancelFunc()
return centrifuge.SubscribeReply{}, nil, nil, centrifuge.ErrorInternal
}
data = decodedData
} else {
data = subscribeRep.Result.Data
}
result := subscribeRep.Result
if result.Override != nil && result.Override.Presence != nil {
presence = result.Override.Presence.Value
}
if result.Override != nil && result.Override.JoinLeave != nil {
joinLeave = result.Override.JoinLeave.Value
}
if result.Override != nil && result.Override.ForcePushJoinLeave != nil {
pushJoinLeave = result.Override.ForcePushJoinLeave.Value
}
expireAt = result.ExpireAt
}
return centrifuge.SubscribeReply{
Options: centrifuge.SubscribeOptions{
ExpireAt: expireAt,
ChannelInfo: info,
EmitPresence: presence,
EmitJoinLeave: joinLeave,
PushJoinLeave: pushJoinLeave,
EnableRecovery: false, // Not used for subscribe stream proxy.
EnablePositioning: false, // Not used for subscribe stream proxy.
Data: data,
Source: subsource.StreamProxy,
HistoryMetaTTL: 0, // Not used for subscribe stream proxy.
},
ClientSideRefresh: true,
SubscriptionReady: subscriptionReady,
}, publishFunc, cancelFunc, nil
}
}
type OnPublication func(pub *proxyproto.Publication, err error)
type ChannelStreamReader interface {
Recv() (*proxyproto.StreamSubscribeResponse, error)
}
// SubscribeStream ...
func (p *SubscribeStreamProxy) SubscribeStream(
ctx context.Context,
bidi bool,
sr *proxyproto.SubscribeRequest,
pubFunc OnPublication,
) (*proxyproto.SubscribeResponse, StreamPublishFunc, func(), error) {
ctx, cancel := context.WithCancel(ctx)
var stream ChannelStreamReader
var publishFunc StreamPublishFunc
if bidi {
bidiStream, err := p.SubscribeBidirectional(ctx)
if err != nil {
cancel()
return nil, nil, nil, err
}
err = bidiStream.Send(&proxyproto.StreamSubscribeRequest{
SubscribeRequest: sr,
})
if err != nil {
cancel()
return nil, nil, nil, err
}
stream = bidiStream
publishFunc = func(data []byte) error {
return bidiStream.Send(&proxyproto.StreamSubscribeRequest{
Publication: &proxyproto.Publication{
Data: data,
},
})
}
} else {
var err error
stream, err = p.SubscribeUnidirectional(ctx, sr)
if err != nil {
cancel()
return nil, nil, nil, err
}
}
firstMessageReceived := make(chan struct{})
go func() {
select {
case <-ctx.Done():
cancel()
return
case <-time.After(p.config.Timeout.ToDuration()):
cancel()
return
case <-firstMessageReceived:
}
}()
resp, err := stream.Recv()
if err != nil {
cancel()
return nil, nil, nil, err
}
close(firstMessageReceived)
go func() {
for {
pubResp, err := stream.Recv()
if err != nil {
cancel()
pubFunc(nil, err)
return
}
pub := pubResp.GetPublication()
if pub != nil {
// TODO: better handling of unexpected nil publication.
pubFunc(pub, nil)
}
}
}()
if resp.SubscribeResponse == nil {
// The reader goroutine was already spawned above, so cancel before
// returning an error or it (and the derived context) leaks.
cancel()
return nil, nil, nil, errors.New("no subscribe response in first message from stream proxy")
}
return resp.SubscribeResponse, publishFunc, cancel, nil
}

View File

@@ -0,0 +1,201 @@
package proxy
import (
"context"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
type proxyGRPCTestServer struct {
flag string
opts proxyGRPCTestServerOptions
proxyproto.UnimplementedCentrifugoProxyServer
}
type proxyGRPCTestServerOptions struct {
User string
ExpireAt int64
B64Data string
Channels []string
Data []byte
}
func newProxyGRPCTestServer(flag string, opts proxyGRPCTestServerOptions) proxyGRPCTestServer {
return proxyGRPCTestServer{
flag: flag,
opts: opts,
}
}
func (p proxyGRPCTestServer) Connect(_ context.Context, _ *proxyproto.ConnectRequest) (*proxyproto.ConnectResponse, error) {
switch p.flag {
case "result":
return &proxyproto.ConnectResponse{
Result: &proxyproto.ConnectResult{
User: p.opts.User,
ExpireAt: p.opts.ExpireAt,
B64Data: p.opts.B64Data,
},
}, nil
case "subscription":
return &proxyproto.ConnectResponse{
Result: &proxyproto.ConnectResult{
User: p.opts.User,
Channels: p.opts.Channels,
},
}, nil
case "subscription error":
return &proxyproto.ConnectResponse{
Result: &proxyproto.ConnectResult{
User: p.opts.User,
Channels: p.opts.Channels,
},
}, nil
case "subscription with recover":
subs := make(map[string]*proxyproto.SubscribeOptions, len(p.opts.Channels))
for _, ch := range p.opts.Channels {
subs[ch] = &proxyproto.SubscribeOptions{CacheRecover: true}
}
return &proxyproto.ConnectResponse{
Result: &proxyproto.ConnectResult{
User: p.opts.User,
Subs: subs,
},
}, nil
case "custom disconnect":
return &proxyproto.ConnectResponse{
Disconnect: p.newDisconnect(),
}, nil
case "custom error":
return &proxyproto.ConnectResponse{
Error: p.newCustomError(),
}, nil
default:
return &proxyproto.ConnectResponse{}, nil
}
}
func (p proxyGRPCTestServer) Refresh(_ context.Context, _ *proxyproto.RefreshRequest) (*proxyproto.RefreshResponse, error) {
switch p.flag {
case "with credentials":
return &proxyproto.RefreshResponse{
Result: &proxyproto.RefreshResult{
B64Info: p.opts.B64Data,
ExpireAt: p.opts.ExpireAt,
},
}, nil
case "expired":
return &proxyproto.RefreshResponse{
Result: &proxyproto.RefreshResult{
Expired: true,
},
}, nil
default:
return &proxyproto.RefreshResponse{}, nil
}
}
func (p proxyGRPCTestServer) Subscribe(_ context.Context, _ *proxyproto.SubscribeRequest) (*proxyproto.SubscribeResponse, error) {
switch p.flag {
case "result":
return &proxyproto.SubscribeResponse{
Result: &proxyproto.SubscribeResult{
B64Info: p.opts.B64Data,
B64Data: p.opts.B64Data,
},
}, nil
case "override":
return &proxyproto.SubscribeResponse{
Result: &proxyproto.SubscribeResult{
B64Info: p.opts.B64Data,
Override: &proxyproto.SubscribeOptionOverride{
Presence: &proxyproto.BoolValue{Value: true},
JoinLeave: &proxyproto.BoolValue{Value: false},
ForcePushJoinLeave: &proxyproto.BoolValue{Value: false},
ForcePositioning: &proxyproto.BoolValue{Value: true},
ForceRecovery: &proxyproto.BoolValue{Value: true},
},
},
}, nil
case "custom disconnect":
return &proxyproto.SubscribeResponse{
Disconnect: p.newDisconnect(),
}, nil
case "custom error":
return &proxyproto.SubscribeResponse{
Error: p.newCustomError(),
}, nil
default:
return &proxyproto.SubscribeResponse{}, nil
}
}
func (p proxyGRPCTestServer) Publish(_ context.Context, _ *proxyproto.PublishRequest) (*proxyproto.PublishResponse, error) {
switch p.flag {
case "result":
return &proxyproto.PublishResponse{
Result: &proxyproto.PublishResult{
B64Data: p.opts.B64Data,
},
}, nil
case "skip history":
return &proxyproto.PublishResponse{
Result: &proxyproto.PublishResult{
B64Data: p.opts.B64Data,
SkipHistory: true,
},
}, nil
case "custom disconnect":
return &proxyproto.PublishResponse{
Disconnect: p.newDisconnect(),
}, nil
case "custom error":
return &proxyproto.PublishResponse{
Error: p.newCustomError(),
}, nil
default:
return &proxyproto.PublishResponse{}, nil
}
}
func (p proxyGRPCTestServer) RPC(_ context.Context, _ *proxyproto.RPCRequest) (*proxyproto.RPCResponse, error) {
switch p.flag {
case "result":
return &proxyproto.RPCResponse{
Result: &proxyproto.RPCResult{
Data: p.opts.Data,
},
}, nil
case "custom disconnect":
return &proxyproto.RPCResponse{
Disconnect: p.newDisconnect(),
}, nil
case "custom error":
return &proxyproto.RPCResponse{
Error: p.newCustomError(),
}, nil
case "custom data":
return &proxyproto.RPCResponse{
Result: &proxyproto.RPCResult{
B64Data: p.opts.B64Data,
},
}, nil
default:
return &proxyproto.RPCResponse{}, nil
}
}
func (p proxyGRPCTestServer) newDisconnect() *proxyproto.Disconnect {
return &proxyproto.Disconnect{
Code: 4000,
Reason: "custom disconnect",
}
}
func (p proxyGRPCTestServer) newCustomError() *proxyproto.Error {
return &proxyproto.Error{
Code: 1000,
Message: "custom error",
}
}

View File

@@ -0,0 +1,94 @@
package proxy
import (
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
)
func transformConnectResponse(err error, statusToCodeTransforms configtypes.HttpStatusToCodeTransforms) (*proxyproto.ConnectResponse, error) {
protocolError, protocolDisconnect := transformHTTPStatusError(err, statusToCodeTransforms)
if protocolError != nil || protocolDisconnect != nil {
return &proxyproto.ConnectResponse{
Error: protocolError,
Disconnect: protocolDisconnect,
}, nil
}
return nil, err
}
func transformPublishResponse(err error, statusToCodeTransforms configtypes.HttpStatusToCodeTransforms) (*proxyproto.PublishResponse, error) {
protocolError, protocolDisconnect := transformHTTPStatusError(err, statusToCodeTransforms)
if protocolError != nil || protocolDisconnect != nil {
return &proxyproto.PublishResponse{
Error: protocolError,
Disconnect: protocolDisconnect,
}, nil
}
return nil, err
}
func transformSubscribeResponse(err error, statusToCodeTransforms configtypes.HttpStatusToCodeTransforms) (*proxyproto.SubscribeResponse, error) {
protocolError, protocolDisconnect := transformHTTPStatusError(err, statusToCodeTransforms)
if protocolError != nil || protocolDisconnect != nil {
return &proxyproto.SubscribeResponse{
Error: protocolError,
Disconnect: protocolDisconnect,
}, nil
}
return nil, err
}
func transformRPCResponse(err error, statusToCodeTransforms configtypes.HttpStatusToCodeTransforms) (*proxyproto.RPCResponse, error) {
protocolError, protocolDisconnect := transformHTTPStatusError(err, statusToCodeTransforms)
if protocolError != nil || protocolDisconnect != nil {
return &proxyproto.RPCResponse{
Error: protocolError,
Disconnect: protocolDisconnect,
}, nil
}
return nil, err
}
func transformSubRefreshResponse(err error, statusToCodeTransforms configtypes.HttpStatusToCodeTransforms) (*proxyproto.SubRefreshResponse, error) {
protocolError, protocolDisconnect := transformHTTPStatusError(err, statusToCodeTransforms)
if protocolError != nil || protocolDisconnect != nil {
return &proxyproto.SubRefreshResponse{
Error: protocolError,
Disconnect: protocolDisconnect,
}, nil
}
return nil, err
}
func transformRefreshResponse(err error, statusToCodeTransforms configtypes.HttpStatusToCodeTransforms) (*proxyproto.RefreshResponse, error) {
protocolError, protocolDisconnect := transformHTTPStatusError(err, statusToCodeTransforms)
if protocolError != nil || protocolDisconnect != nil {
return &proxyproto.RefreshResponse{
Error: protocolError,
Disconnect: protocolDisconnect,
}, nil
}
return nil, err
}
func transformMapPublishResponse(err error, statusToCodeTransforms configtypes.HttpStatusToCodeTransforms) (*proxyproto.MapPublishResponse, error) {
protocolError, protocolDisconnect := transformHTTPStatusError(err, statusToCodeTransforms)
if protocolError != nil || protocolDisconnect != nil {
return &proxyproto.MapPublishResponse{
Error: protocolError,
Disconnect: protocolDisconnect,
}, nil
}
return nil, err
}
func transformMapRemoveResponse(err error, statusToCodeTransforms configtypes.HttpStatusToCodeTransforms) (*proxyproto.MapRemoveResponse, error) {
protocolError, protocolDisconnect := transformHTTPStatusError(err, statusToCodeTransforms)
if protocolError != nil || protocolDisconnect != nil {
return &proxyproto.MapRemoveResponse{
Error: protocolError,
Disconnect: protocolDisconnect,
}, nil
}
return nil, err
}