snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
16
internal/proxy/client.go
Normal file
16
internal/proxy/client.go
Normal 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
19
internal/proxy/connect.go
Normal 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
|
||||
}
|
||||
59
internal/proxy/connect_grpc.go
Normal file
59
internal/proxy/connect_grpc.go
Normal 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)
|
||||
}
|
||||
243
internal/proxy/connect_handler.go
Normal file
243
internal/proxy/connect_handler.go
Normal 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: ¢rifuge.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
|
||||
}
|
||||
}
|
||||
449
internal/proxy/connect_handler_test.go
Normal file
449
internal/proxy/connect_handler_test.go
Normal 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: ¢rifuge.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)
|
||||
}
|
||||
}
|
||||
55
internal/proxy/connect_http.go
Normal file
55
internal/proxy/connect_http.go
Normal 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
126
internal/proxy/grpc.go
Normal 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
181
internal/proxy/http.go
Normal 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
|
||||
}
|
||||
18
internal/proxy/map_publish.go
Normal file
18
internal/proxy/map_publish.go
Normal 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
|
||||
}
|
||||
60
internal/proxy/map_publish_grpc.go
Normal file
60
internal/proxy/map_publish_grpc.go
Normal 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
|
||||
}
|
||||
175
internal/proxy/map_publish_handler.go
Normal file
175
internal/proxy/map_publish_handler.go
Normal 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
|
||||
}
|
||||
}
|
||||
75
internal/proxy/map_publish_handler_test.go
Normal file
75
internal/proxy/map_publish_handler_test.go
Normal 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)
|
||||
}
|
||||
70
internal/proxy/map_publish_http.go
Normal file
70
internal/proxy/map_publish_http.go
Normal 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
|
||||
}
|
||||
18
internal/proxy/map_remove.go
Normal file
18
internal/proxy/map_remove.go
Normal 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
|
||||
}
|
||||
60
internal/proxy/map_remove_grpc.go
Normal file
60
internal/proxy/map_remove_grpc.go
Normal 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
|
||||
}
|
||||
136
internal/proxy/map_remove_handler.go
Normal file
136
internal/proxy/map_remove_handler.go
Normal 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
|
||||
}
|
||||
}
|
||||
65
internal/proxy/map_remove_http.go
Normal file
65
internal/proxy/map_remove_http.go
Normal 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
168
internal/proxy/proxy.go
Normal 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
|
||||
}
|
||||
447
internal/proxy/proxy_test.go
Normal file
447
internal/proxy/proxy_test.go
Normal 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
18
internal/proxy/publish.go
Normal 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
|
||||
}
|
||||
60
internal/proxy/publish_grpc.go
Normal file
60
internal/proxy/publish_grpc.go
Normal 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
|
||||
}
|
||||
175
internal/proxy/publish_handler.go
Normal file
175
internal/proxy/publish_handler.go
Normal 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
|
||||
}
|
||||
}
|
||||
319
internal/proxy/publish_handler_test.go
Normal file
319
internal/proxy/publish_handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
69
internal/proxy/publish_http.go
Normal file
69
internal/proxy/publish_http.go
Normal 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
20
internal/proxy/refresh.go
Normal 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
|
||||
}
|
||||
65
internal/proxy/refresh_grpc.go
Normal file
65
internal/proxy/refresh_grpc.go
Normal 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
|
||||
}
|
||||
127
internal/proxy/refresh_handler.go
Normal file
127
internal/proxy/refresh_handler.go
Normal 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
|
||||
}
|
||||
}
|
||||
221
internal/proxy/refresh_handler_test.go
Normal file
221
internal/proxy/refresh_handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
61
internal/proxy/refresh_http.go
Normal file
61
internal/proxy/refresh_http.go
Normal 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
18
internal/proxy/rpc.go
Normal 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
|
||||
}
|
||||
60
internal/proxy/rpc_grpc.go
Normal file
60
internal/proxy/rpc_grpc.go
Normal 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
|
||||
}
|
||||
149
internal/proxy/rpc_handler.go
Normal file
149
internal/proxy/rpc_handler.go
Normal 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
|
||||
}
|
||||
}
|
||||
264
internal/proxy/rpc_handler_test.go
Normal file
264
internal/proxy/rpc_handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
56
internal/proxy/rpc_http.go
Normal file
56
internal/proxy/rpc_http.go
Normal 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
|
||||
}
|
||||
14
internal/proxy/shared_poll_refresh.go
Normal file
14
internal/proxy/shared_poll_refresh.go
Normal 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
|
||||
}
|
||||
50
internal/proxy/shared_poll_refresh_grpc.go
Normal file
50
internal/proxy/shared_poll_refresh_grpc.go
Normal 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"
|
||||
}
|
||||
92
internal/proxy/shared_poll_refresh_handler.go
Normal file
92
internal/proxy/shared_poll_refresh_handler.go
Normal 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
|
||||
}
|
||||
}
|
||||
312
internal/proxy/shared_poll_refresh_handler_test.go
Normal file
312
internal/proxy/shared_poll_refresh_handler_test.go
Normal 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
|
||||
}
|
||||
46
internal/proxy/shared_poll_refresh_http.go
Normal file
46
internal/proxy/shared_poll_refresh_http.go
Normal 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"
|
||||
}
|
||||
18
internal/proxy/sub_refresh.go
Normal file
18
internal/proxy/sub_refresh.go
Normal 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
|
||||
}
|
||||
60
internal/proxy/sub_refresh_grpc.go
Normal file
60
internal/proxy/sub_refresh_grpc.go
Normal 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
|
||||
}
|
||||
153
internal/proxy/sub_refresh_handler.go
Normal file
153
internal/proxy/sub_refresh_handler.go
Normal 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
|
||||
}
|
||||
}
|
||||
56
internal/proxy/sub_refresh_http.go
Normal file
56
internal/proxy/sub_refresh_http.go
Normal 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
|
||||
}
|
||||
18
internal/proxy/subscribe.go
Normal file
18
internal/proxy/subscribe.go
Normal 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
|
||||
}
|
||||
60
internal/proxy/subscribe_grpc.go
Normal file
60
internal/proxy/subscribe_grpc.go
Normal 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
|
||||
}
|
||||
200
internal/proxy/subscribe_handler.go
Normal file
200
internal/proxy/subscribe_handler.go
Normal 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
|
||||
}
|
||||
}
|
||||
368
internal/proxy/subscribe_handler_test.go
Normal file
368
internal/proxy/subscribe_handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
56
internal/proxy/subscribe_http.go
Normal file
56
internal/proxy/subscribe_http.go
Normal 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
|
||||
}
|
||||
45
internal/proxy/subscribe_stream_grpc.go
Normal file
45
internal/proxy/subscribe_stream_grpc.go
Normal 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))
|
||||
}
|
||||
354
internal/proxy/subscribe_stream_handler.go
Normal file
354
internal/proxy/subscribe_stream_handler.go
Normal 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, ¢rifuge.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, ¢rifuge.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, ¢rifuge.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
|
||||
}
|
||||
201
internal/proxy/test_grpc_server.go
Normal file
201
internal/proxy/test_grpc_server.go
Normal 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",
|
||||
}
|
||||
}
|
||||
94
internal/proxy/transform.go
Normal file
94
internal/proxy/transform.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user