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

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

201
internal/admin/handlers.go Normal file
View File

@@ -0,0 +1,201 @@
package admin
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/centrifugal/centrifugo/v6/internal/api"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/middleware"
"github.com/centrifugal/centrifugo/v6/internal/reverseproxy"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifugo/v6/internal/webui"
"github.com/centrifugal/centrifuge"
"github.com/gorilla/securecookie"
"github.com/rs/zerolog/log"
)
type Config = configtypes.Admin
// Handler handles admin web UI endpoints.
type Handler struct {
mux *http.ServeMux
node *centrifuge.Node
config configtypes.Admin
}
// NewHandler creates new Handler.
func NewHandler(n *centrifuge.Node, apiExecutor *api.Executor, c Config) *Handler {
h := &Handler{
node: n,
config: c,
}
mux := http.NewServeMux()
prefix := strings.TrimRight(h.config.HandlerPrefix, "/")
mux.Handle(prefix+"/admin/init", http.HandlerFunc(h.initHandler))
// Throttle repeated failed password attempts per client IP to slow brute-force
// of admin.password. Only failures count and each IP is tracked independently,
// so a valid login is never throttled - including while another source attacks.
//
// This is best-effort, in-process protection only. The admin auth endpoint is
// intended to be reachable by trusted operators, and Centrifugo recommends
// restricting access to it at the infrastructure level (firewall rules, a
// private network, an authenticating reverse proxy, etc.). The throttle here
// raises the bar for brute-force but is not a substitute for that: the primary
// protection of the admin endpoint must be done at the infrastructure level.
authThrottle := middleware.NewAuthThrottle(10, time.Minute, nil)
mux.Handle(prefix+"/admin/auth", middleware.Post(authThrottle.Middleware(http.HandlerFunc(h.authHandler))))
mux.Handle(prefix+"/admin/api", middleware.Post(h.adminSecureTokenAuth(api.NewHandler(n, apiExecutor, api.Config{}).OldRoute())))
webPrefix := prefix + "/"
if c.WebProxyAddress != "" {
log.Info().Str("address", c.WebProxyAddress).Msg("using admin web reverse proxy")
proxy, err := reverseproxy.New(c.WebProxyAddress)
if err != nil {
log.Fatal().Err(err).Msg("error creating admin web reverse proxy")
}
mux.HandleFunc(webPrefix, reverseproxy.ProxyRequestHandler(proxy))
} else if c.WebPath != "" {
mux.Handle(webPrefix, http.StripPrefix(webPrefix, http.FileServer(http.Dir(c.WebPath))))
} else {
mux.Handle(webPrefix, http.StripPrefix(webPrefix, http.FileServer(webui.FS)))
}
h.mux = mux
return h
}
func (s *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(rw, r)
}
func (s *Handler) adminSecureTokenAuth(h http.Handler) http.Handler {
secret := s.config.Secret
insecure := s.config.Insecure
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if insecure {
h.ServeHTTP(w, r)
return
}
if secret == "" {
log.Error().Msg("no admin secret key found in configuration")
w.WriteHeader(http.StatusUnauthorized)
return
}
var token string
authorization := r.Header.Get("Authorization")
if authorization != "" {
parts := strings.Fields(authorization)
if len(parts) != 2 {
w.WriteHeader(http.StatusUnauthorized)
return
}
authMethod := strings.ToLower(parts[0])
if authMethod != "token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
token = parts[1]
}
if token == "" || !checkSecureAdminToken(secret, token) {
w.WriteHeader(http.StatusUnauthorized)
return
}
h.ServeHTTP(w, r)
})
}
// initHandler allows to get admin web interface settings.
func (s *Handler) initHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"insecure": s.config.Insecure,
"edition": "oss",
}
secret := s.config.Secret
authenticated := false
if s.config.Insecure {
authenticated = true
} else if secret != "" {
var token string
authorization := r.Header.Get("Authorization")
if authorization != "" {
parts := strings.Fields(authorization)
if len(parts) == 2 && strings.ToLower(parts[0]) == "token" {
token = parts[1]
}
}
if token != "" && checkSecureAdminToken(secret, token) {
authenticated = true
}
}
resp["authenticated"] = authenticated
_ = json.NewEncoder(w).Encode(resp)
}
// authHandler allows to get admin web interface token.
func (s *Handler) authHandler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1024*1024) // 1MB limit.
formPassword := r.FormValue("password")
password := s.config.Password
secret := s.config.Secret
if password == "" || secret == "" {
log.Error().Msg("admin_password and admin_secret must be set in configuration")
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if tools.SecureCompareString(formPassword, password) {
w.Header().Set("Content-Type", "application/json")
token, err := generateSecureAdminToken(secret)
if err != nil {
log.Error().Msgf("error generating admin token: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
resp := map[string]string{
"token": token,
}
_ = json.NewEncoder(w).Encode(resp)
return
}
http.Error(w, "Bad Request", http.StatusBadRequest)
}
const (
// AdminTokenKey is a key for admin authorization token.
secureAdminTokenKey = "token"
// AdminTokenValue is a value for secure admin authorization token.
secureAdminTokenValue = "authorized"
)
// generateSecureAdminToken generates admin authentication token.
func generateSecureAdminToken(secret string) (string, error) {
s := securecookie.New([]byte(secret), nil)
return s.Encode(secureAdminTokenKey, secureAdminTokenValue)
}
// checkSecureAdminToken checks admin connection token which Centrifugo returns after admin login.
func checkSecureAdminToken(secret string, token string) bool {
s := securecookie.New([]byte(secret), nil)
var val string
err := s.Decode(secureAdminTokenKey, token, &val)
if err != nil {
return false
}
if val != secureAdminTokenValue {
return false
}
return true
}

View File

@@ -0,0 +1,218 @@
package admin
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/centrifugal/centrifuge"
"github.com/stretchr/testify/require"
)
// TestNewHandler ensures NewHandler sets up routes and proxy handling correctly.
func TestNewHandler(t *testing.T) {
node := &centrifuge.Node{}
cfg := Config{HandlerPrefix: "/prefix", WebProxyAddress: "", WebPath: ""}
handler := NewHandler(node, nil, cfg)
require.NotNil(t, handler)
require.NotNil(t, handler.mux)
// Validate init route is registered.
req := httptest.NewRequest("GET", "/prefix/admin/init", nil)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
// TestInitHandler checks the initHandler returns correct settings.
func TestInitHandler(t *testing.T) {
config := Config{}
handler := &Handler{config: config}
req := httptest.NewRequest("GET", "/admin/init", nil)
resp := httptest.NewRecorder()
handler.initHandler(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var response map[string]any
err := json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
require.Equal(t, "oss", response["edition"])
require.Equal(t, false, response["insecure"])
require.Equal(t, false, response["authenticated"])
}
func TestInitHandler_Insecure(t *testing.T) {
config := Config{Insecure: true}
handler := &Handler{config: config}
req := httptest.NewRequest("GET", "/admin/init", nil)
resp := httptest.NewRecorder()
handler.initHandler(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var response map[string]any
err := json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
require.Equal(t, "oss", response["edition"])
require.Equal(t, true, response["insecure"])
require.Equal(t, true, response["authenticated"]) // Since insecure on.
}
// TestAuthHandler_NoPasswordOrSecret tests authHandler error when password or secret is missing.
func TestAuthHandler_NoPasswordOrSecret(t *testing.T) {
config := Config{Password: "", Secret: ""}
handler := &Handler{config: config}
req := httptest.NewRequest("POST", "/admin/auth", nil)
resp := httptest.NewRecorder()
handler.authHandler(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
}
// TestAuthHandler_ValidPassword tests authHandler token generation with valid password.
func TestAuthHandler_ValidPassword(t *testing.T) {
config := Config{Password: "test-password", Secret: "test-secret"}
handler := &Handler{config: config}
form := url.Values{}
form.Add("password", "test-password")
req := httptest.NewRequest("POST", "/admin/auth", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp := httptest.NewRecorder()
handler.authHandler(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var response map[string]string
err := json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
require.NotEmpty(t, response["token"])
}
// TestAuthHandler_InvalidPassword tests authHandler rejection with invalid password.
func TestAuthHandler_InvalidPassword(t *testing.T) {
config := Config{Password: "test-password", Secret: "test-secret"}
handler := &Handler{config: config}
form := url.Values{}
form.Add("password", "wrong-password")
req := httptest.NewRequest("POST", "/admin/auth", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp := httptest.NewRecorder()
handler.authHandler(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
}
// TestAuthHandler_Throttled ensures the /admin/auth route throttles repeated
// failed password attempts per client IP, without blocking a valid login from
// another IP while an attacker is brute-forcing.
func TestAuthHandler_Throttled(t *testing.T) {
node := &centrifuge.Node{}
cfg := Config{Password: "test-password", Secret: "test-secret"}
handler := NewHandler(node, nil, cfg)
post := func(ip, password string) int {
form := url.Values{}
form.Add("password", password)
req := httptest.NewRequest("POST", "/admin/auth", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = "10.0.0.1:12345" // trusted local proxy peer, so X-Real-IP is honored.
req.Header.Set("X-Real-IP", ip)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
return resp.Code
}
// The default limit is 10 failures per IP per window.
for i := 0; i < 10; i++ {
require.Equal(t, http.StatusBadRequest, post("6.6.6.6", "wrong"), "attempt %d", i)
}
require.Equal(t, http.StatusTooManyRequests, post("6.6.6.6", "wrong"))
// The attacker cannot tell a correct guess from a throttled one.
require.Equal(t, http.StatusTooManyRequests, post("6.6.6.6", "test-password"))
// A valid login from a different IP still succeeds during the attack.
require.Equal(t, http.StatusOK, post("7.7.7.7", "test-password"))
}
// TestAdminSecureTokenAuth_InsecureMode tests adminSecureTokenAuth allows request in insecure mode.
func TestAdminSecureTokenAuth_InsecureMode(t *testing.T) {
config := Config{Insecure: true}
handler := &Handler{config: config}
// Mocked handler that should be invoked
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
authHandler := handler.adminSecureTokenAuth(finalHandler)
req := httptest.NewRequest("GET", "/admin/api", nil)
resp := httptest.NewRecorder()
authHandler.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
// TestAdminSecureTokenAuth_MissingToken tests adminSecureTokenAuth rejection when token is missing.
func TestAdminSecureTokenAuth_MissingToken(t *testing.T) {
config := Config{Secret: "test-secret"}
handler := &Handler{config: config}
// Mocked handler that should not be invoked
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
authHandler := handler.adminSecureTokenAuth(finalHandler)
req := httptest.NewRequest("GET", "/admin/api", nil)
resp := httptest.NewRecorder()
authHandler.ServeHTTP(resp, req)
require.Equal(t, http.StatusUnauthorized, resp.Code)
}
// TestAdminSecureTokenAuth_ValidToken tests adminSecureTokenAuth with valid token.
func TestAdminSecureTokenAuth_ValidToken(t *testing.T) {
config := Config{Secret: "test-secret"}
handler := &Handler{config: config}
// Mocked handler that should be invoked
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Generate a valid token
token, err := generateSecureAdminToken("test-secret")
require.NoError(t, err)
authHandler := handler.adminSecureTokenAuth(finalHandler)
req := httptest.NewRequest("GET", "/admin/api", nil)
req.Header.Set("Authorization", "token "+token)
resp := httptest.NewRecorder()
authHandler.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
// TestAdminSecureTokenAuth_InvalidToken tests adminSecureTokenAuth rejection with invalid token.
func TestAdminSecureTokenAuth_InvalidToken(t *testing.T) {
config := Config{Secret: "test-secret"}
handler := &Handler{config: config}
// Mocked handler that should not be invoked.
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Fail(t, "final handler should not be invoked")
w.WriteHeader(http.StatusOK)
})
authHandler := handler.adminSecureTokenAuth(finalHandler)
req := httptest.NewRequest("GET", "/admin/api", nil)
req.Header.Set("Authorization", "token invalid-token")
resp := httptest.NewRecorder()
authHandler.ServeHTTP(resp, req)
require.Equal(t, http.StatusUnauthorized, resp.Code)
}

1340
internal/api/api.go Normal file

File diff suppressed because it is too large Load Diff

747
internal/api/api_test.go Normal file
View File

@@ -0,0 +1,747 @@
package api
import (
"context"
"encoding/base64"
"fmt"
"os"
"testing"
"time"
. "github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)
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())
}
func nodeWithMemoryEngine() *centrifuge.Node {
n, err := centrifuge.New(centrifuge.Config{})
if err != nil {
panic(err)
}
err = n.Run()
if err != nil {
panic(err)
}
return n
}
type testSurveyCaller struct{}
func (t testSurveyCaller) Channels(_ context.Context, _ *ChannelsRequest) (map[string]*ChannelInfo, error) {
return nil, nil
}
func (t testSurveyCaller) Connections(_ context.Context, _ *ConnectionsRequest) (map[string]*ConnectionInfo, error) {
return nil, nil
}
func TestPublishAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Publish(context.Background(), &PublishRequest{})
require.Equal(t, ErrorBadRequest, resp.Error)
resp = api.Publish(context.Background(), &PublishRequest{Channel: "test"})
require.Equal(t, ErrorBadRequest, resp.Error)
resp = api.Publish(context.Background(), &PublishRequest{Channel: "test", Data: []byte("test")})
require.Nil(t, resp.Error)
resp = api.Publish(context.Background(), &PublishRequest{Channel: "test:test", Data: []byte("test")})
require.Equal(t, ErrorUnknownChannel, resp.Error)
}
// TestBatchAPINilCommand guards against a crash on a nil batch command (e.g. a
// JSON `null` array element). In parallel mode each command runs in its own
// goroutine, so a nil-deref would escape net/http's recover and crash the node.
func TestBatchAPINilCommand(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
for _, parallel := range []bool{false, true} {
resp := api.Batch(context.Background(), &BatchRequest{
Parallel: parallel,
Commands: []*Command{
nil,
{Publish: &PublishRequest{Channel: "test", Data: []byte("x")}},
},
})
require.Len(t, resp.Replies, 2)
require.Equal(t, ErrorBadRequest, resp.Replies[0].Error)
require.Nil(t, resp.Replies[1].Error)
}
}
func TestBroadcastAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Broadcast(context.Background(), &BroadcastRequest{})
require.Equal(t, ErrorBadRequest, resp.Error)
// Empty data validation now happens per-channel
resp = api.Broadcast(context.Background(), &BroadcastRequest{Channels: []string{"test"}})
require.Nil(t, resp.Error)
require.Equal(t, ErrorBadRequest.Code, resp.Result.Responses[0].Error.Code)
resp = api.Broadcast(context.Background(), &BroadcastRequest{Channels: []string{"test"}, Data: []byte("test")})
require.Nil(t, resp.Error)
resp = api.Broadcast(context.Background(), &BroadcastRequest{Channels: []string{"test:test"}, Data: []byte("test")})
require.Nil(t, resp.Error)
require.Equal(t, ErrorUnknownChannel, resp.Result.Responses[0].Error)
resp = api.Broadcast(context.Background(), &BroadcastRequest{Channels: []string{"test", "test:test"}, Data: []byte("test")})
require.Nil(t, resp.Error)
require.Equal(t, ErrorUnknownChannel, resp.Result.Responses[1].Error)
}
func TestPublishAPI_DataFormat_JSON(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfg.Channel.WithoutNamespace.PublicationDataFormat = configtypes.PublicationDataFormatJSON
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
// Valid JSON should succeed
resp := api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`{"key": "value"}`),
})
require.Nil(t, resp.Error)
// Valid JSON array should succeed
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`[1, 2, 3]`),
})
require.Nil(t, resp.Error)
// Valid JSON primitives should succeed
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`"string"`),
})
require.Nil(t, resp.Error)
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`123`),
})
require.Nil(t, resp.Error)
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`true`),
})
require.Nil(t, resp.Error)
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`null`),
})
require.Nil(t, resp.Error)
// Invalid JSON should fail
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`not valid json`),
})
require.NotNil(t, resp.Error)
require.Equal(t, ErrorBadRequest.Code, resp.Error.Code)
// Empty data with JSON format should fail
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte{},
})
require.NotNil(t, resp.Error)
require.Equal(t, ErrorBadRequest.Code, resp.Error.Code)
}
func TestPublishAPI_DataFormat_Binary(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfg.Channel.WithoutNamespace.PublicationDataFormat = configtypes.PublicationDataFormatBinary
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
// Binary data should succeed
resp := api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte{0x01, 0x02, 0x03},
})
require.Nil(t, resp.Error)
// Empty data with binary format should succeed
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte{},
})
require.Nil(t, resp.Error)
// Any data should succeed with binary format
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte("any text data"),
})
require.Nil(t, resp.Error)
}
func TestPublishAPI_DataFormat_Default(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
// Default format (empty string) - should reject empty data
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
// Non-empty data should succeed
resp := api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte("any data"),
})
require.Nil(t, resp.Error)
// Empty data should fail (current behavior preserved)
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte{},
})
require.NotNil(t, resp.Error)
require.Equal(t, ErrorBadRequest.Code, resp.Error.Code)
}
func TestBroadcastAPI_DataFormat_JSON(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfg.Channel.WithoutNamespace.PublicationDataFormat = configtypes.PublicationDataFormatJSON
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
// Valid JSON should succeed
resp := api.Broadcast(context.Background(), &BroadcastRequest{
Channels: []string{"test", "test2"},
Data: []byte(`{"key": "value"}`),
})
require.Nil(t, resp.Error)
require.Nil(t, resp.Result.Responses[0].Error)
require.Nil(t, resp.Result.Responses[1].Error)
// Invalid JSON should fail for all channels
resp = api.Broadcast(context.Background(), &BroadcastRequest{
Channels: []string{"test", "test2"},
Data: []byte(`not valid json`),
})
require.Nil(t, resp.Error)
require.Equal(t, ErrorBadRequest.Code, resp.Result.Responses[0].Error.Code)
require.Equal(t, ErrorBadRequest.Code, resp.Result.Responses[1].Error.Code)
}
func TestBroadcastAPI_DataFormat_Binary(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfg.Channel.WithoutNamespace.PublicationDataFormat = configtypes.PublicationDataFormatBinary
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
// Empty data should succeed with binary format
resp := api.Broadcast(context.Background(), &BroadcastRequest{
Channels: []string{"test", "test2"},
Data: []byte{},
})
require.Nil(t, resp.Error)
require.Nil(t, resp.Result.Responses[0].Error)
require.Nil(t, resp.Result.Responses[1].Error)
}
func TestHistoryAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.History(context.Background(), &HistoryRequest{})
require.Equal(t, ErrorBadRequest, resp.Error)
resp = api.History(context.Background(), &HistoryRequest{Channel: "test"})
require.Equal(t, ErrorNotAvailable, resp.Error)
cfg = cfgContainer.Config()
cfg.Channel.WithoutNamespace.HistorySize = 1
cfg.Channel.WithoutNamespace.HistoryTTL = configtypes.Duration(1 * time.Second)
_ = cfgContainer.Reload(cfg)
resp = api.History(context.Background(), &HistoryRequest{Channel: "test"})
require.Nil(t, resp.Error)
}
func TestHistoryRemoveAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.HistoryRemove(context.Background(), &HistoryRemoveRequest{})
require.Equal(t, ErrorBadRequest, resp.Error)
resp = api.HistoryRemove(context.Background(), &HistoryRemoveRequest{Channel: "test"})
require.Equal(t, ErrorNotAvailable, resp.Error)
cfg = cfgContainer.Config()
cfg.Channel.WithoutNamespace.HistorySize = 1
cfg.Channel.WithoutNamespace.HistoryTTL = configtypes.Duration(time.Second)
_ = cfgContainer.Reload(cfg)
resp = api.HistoryRemove(context.Background(), &HistoryRemoveRequest{Channel: "test"})
require.Nil(t, resp.Error)
}
func TestPresenceAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Presence(context.Background(), &PresenceRequest{})
require.Equal(t, ErrorBadRequest, resp.Error)
resp = api.Presence(context.Background(), &PresenceRequest{Channel: "test"})
require.Equal(t, ErrorNotAvailable, resp.Error)
cfg = cfgContainer.Config()
cfg.Channel.WithoutNamespace.Presence = true
_ = cfgContainer.Reload(cfg)
resp = api.Presence(context.Background(), &PresenceRequest{Channel: "test"})
require.Nil(t, resp.Error)
}
func TestPresenceStatsAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.PresenceStats(context.Background(), &PresenceStatsRequest{})
require.Equal(t, ErrorBadRequest, resp.Error)
resp = api.PresenceStats(context.Background(), &PresenceStatsRequest{Channel: "test"})
require.Equal(t, ErrorNotAvailable, resp.Error)
cfg = cfgContainer.Config()
cfg.Channel.WithoutNamespace.Presence = true
_ = cfgContainer.Reload(cfg)
resp = api.PresenceStats(context.Background(), &PresenceStatsRequest{Channel: "test"})
require.Nil(t, resp.Error)
}
func TestDisconnectAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Disconnect(context.Background(), &DisconnectRequest{
User: "test",
})
require.Nil(t, resp.Error)
}
func TestUnsubscribeAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Unsubscribe(context.Background(), &UnsubscribeRequest{
User: "test",
Channel: "test",
})
require.Nil(t, resp.Error)
}
func TestRefreshAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Refresh(context.Background(), &RefreshRequest{
User: "test",
})
require.Nil(t, resp.Error)
}
func TestSubscribeAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Subscribe(context.Background(), &SubscribeRequest{
User: "test",
Channel: "test",
})
require.Nil(t, resp.Error)
}
func TestInfoAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Info(context.Background(), &InfoRequest{})
require.Nil(t, resp.Error)
}
// configWithNamespace returns a DefaultConfig with a single namespace having the
// given name and subscription_type. Used to test the subscription_type gates on
// publish/map_*/shared_poll_* API endpoints. Fills the minimum mandatory fields
// (map.mode for map types, global shared_poll.hmac_secret_key for shared_poll)
// so the config passes Validate().
func configWithNamespace(name, subscriptionType string) config.Config {
cfg := config.DefaultConfig()
chOpts := configtypes.ChannelOptions{
SubscriptionType: subscriptionType,
}
switch subscriptionType {
case "map":
// Persistent mode requires no key_ttl and is the simplest minimum.
chOpts.Map = configtypes.MapConfig{Mode: "persistent"}
case "map_clients", "map_users":
// Persistent mode is forbidden here (presence entries need TTL-based
// cleanup), so use ephemeral with a key_ttl.
chOpts.Map = configtypes.MapConfig{
Mode: "ephemeral",
KeyTTL: configtypes.Duration(time.Minute),
}
case "shared_poll":
// Global HMAC secret required for any shared_poll namespace.
cfg.SharedPoll.HMACSecretKey = "test-secret"
// Default refresh proxy must have an endpoint when no explicit
// proxy_name is set on the namespace.
cfg.Channel.Proxy.SharedPollRefresh = configtypes.Proxy{
Endpoint: "http://127.0.0.1:0/test",
Timeout: configtypes.Duration(time.Second),
}
chOpts.SharedPoll = configtypes.SharedPollConfig{Mode: "versioned"}
}
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: name,
ChannelOptions: chOpts,
},
}
return cfg
}
// TestPublishAPI_RejectsMapAndSharedPollNamespaces ensures the stream Publish
// endpoint rejects calls aimed at namespaces configured for map or shared poll
// subscriptions — those have their own dedicated APIs.
func TestPublishAPI_RejectsMapAndSharedPollNamespaces(t *testing.T) {
cases := []struct {
subscriptionType string
}{
{"map"},
{"map_clients"},
{"map_users"},
{"shared_poll"},
}
for _, c := range cases {
t.Run(c.subscriptionType, func(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := configWithNamespace("ns", c.subscriptionType)
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Publish(context.Background(), &PublishRequest{Channel: "ns:test", Data: []byte("data")})
require.Equal(t, ErrorBadRequest, resp.Error)
})
}
}
// TestMapPublishAPI_RejectsNonMapNamespaces ensures map_publish rejects calls
// against namespaces that aren't configured for map subscriptions.
func TestMapPublishAPI_RejectsNonMapNamespaces(t *testing.T) {
cases := []struct {
subscriptionType string
}{
{""},
{"stream"},
{"shared_poll"},
}
for _, c := range cases {
t.Run("type="+c.subscriptionType, func(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := configWithNamespace("ns", c.subscriptionType)
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.MapPublish(context.Background(), &MapPublishRequest{
Channel: "ns:test",
Key: "k",
Data: []byte(`{"v":1}`),
})
require.Equal(t, ErrorBadRequest, resp.Error)
})
}
}
// TestMapPublishAPI_RejectsInvalidKeyMode ensures key_mode is validated against
// the allowed set ("", "if_new", "if_exists").
func TestMapPublishAPI_RejectsInvalidKeyMode(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := configWithNamespace("ns", "map")
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.MapPublish(context.Background(), &MapPublishRequest{
Channel: "ns:test",
Key: "k",
Data: []byte(`{"v":1}`),
KeyMode: "bogus",
})
require.Equal(t, ErrorBadRequest, resp.Error)
}
// TestMapAPIs_RejectNonMapNamespaces ensures the other map API endpoints
// (remove / read_state / read_stream / stats / clear) reject calls against
// namespaces that aren't configured for map subscriptions, instead of leaking
// internal errors from the broker.
func TestMapAPIs_RejectNonMapNamespaces(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := configWithNamespace("ns", "") // default = stream
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
rmResp := api.MapRemove(context.Background(), &MapRemoveRequest{Channel: "ns:test", Key: "k"})
require.Equal(t, ErrorBadRequest, rmResp.Error)
rsResp := api.MapReadState(context.Background(), &MapReadStateRequest{Channel: "ns:test"})
require.Equal(t, ErrorBadRequest, rsResp.Error)
rstrResp := api.MapReadStream(context.Background(), &MapReadStreamRequest{Channel: "ns:test"})
require.Equal(t, ErrorBadRequest, rstrResp.Error)
stResp := api.MapStats(context.Background(), &MapStatsRequest{Channel: "ns:test"})
require.Equal(t, ErrorBadRequest, stResp.Error)
clResp := api.MapClear(context.Background(), &MapClearRequest{Channel: "ns:test"})
require.Equal(t, ErrorBadRequest, clResp.Error)
}
// TestMapAPIs_RejectUnknownChannel ensures the map API endpoints return
// ErrorUnknownChannel instead of leaking internal errors when the channel's
// namespace doesn't exist.
func TestMapAPIs_RejectUnknownChannel(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
// "missing:test" — namespace "missing" not configured.
pubResp := api.MapPublish(context.Background(), &MapPublishRequest{Channel: "missing:test", Key: "k", Data: []byte(`{}`)})
require.Equal(t, ErrorUnknownChannel, pubResp.Error)
rmResp := api.MapRemove(context.Background(), &MapRemoveRequest{Channel: "missing:test", Key: "k"})
require.Equal(t, ErrorUnknownChannel, rmResp.Error)
rsResp := api.MapReadState(context.Background(), &MapReadStateRequest{Channel: "missing:test"})
require.Equal(t, ErrorUnknownChannel, rsResp.Error)
rstrResp := api.MapReadStream(context.Background(), &MapReadStreamRequest{Channel: "missing:test"})
require.Equal(t, ErrorUnknownChannel, rstrResp.Error)
stResp := api.MapStats(context.Background(), &MapStatsRequest{Channel: "missing:test"})
require.Equal(t, ErrorUnknownChannel, stResp.Error)
clResp := api.MapClear(context.Background(), &MapClearRequest{Channel: "missing:test"})
require.Equal(t, ErrorUnknownChannel, clResp.Error)
}
// nodeWithMapSupport builds a node able to actually serve map operations for
// channels of the given config container, so that valid publishes really reach
// the map broker instead of failing on options resolution.
func nodeWithMapSupport(cfgContainer *config.Container) *centrifuge.Node {
n, err := centrifuge.New(centrifuge.Config{
Map: centrifuge.MapConfig{
GetMapChannelOptions: func(channel string) centrifuge.MapChannelOptions {
_, _, chOpts, ok, err := cfgContainer.ChannelOptions(channel)
if err != nil || !ok {
return centrifuge.MapChannelOptions{}
}
var mode centrifuge.MapMode
switch chOpts.Map.Mode {
case "ephemeral":
mode = centrifuge.MapModeEphemeral
case "recoverable":
mode = centrifuge.MapModeRecoverable
case "persistent":
mode = centrifuge.MapModePersistent
}
return centrifuge.MapChannelOptions{Mode: mode, KeyTTL: chOpts.Map.KeyTTL.ToDuration()}
},
},
})
if err != nil {
panic(err)
}
if err = n.Run(); err != nil {
panic(err)
}
return n
}
// TestMapPublishAPI_DataFormat ensures map_publish applies the namespace
// publication_data_format policy, same as the stream Publish endpoint does.
func TestMapPublishAPI_DataFormat(t *testing.T) {
cases := []struct {
format string
data []byte
b64Data string
wantReject bool
}{
{configtypes.PublicationDataFormatJSONObject, []byte(`{"v":1}`), "", false},
{configtypes.PublicationDataFormatJSONObject, []byte(`[1,2]`), "", true},
{configtypes.PublicationDataFormatJSONObject, []byte(`not-json-object`), "", true},
{configtypes.PublicationDataFormatJSON, []byte(`[1,2]`), "", false},
{configtypes.PublicationDataFormatJSON, []byte(`nope`), "", true},
{configtypes.PublicationDataFormatBinary, nil, "", false},
{"", []byte(`whatever`), "", false},
{"", nil, "", true}, // default format rejects empty data
// b64data must be validated after decoding, not before.
{configtypes.PublicationDataFormatJSONObject, nil, base64.StdEncoding.EncodeToString([]byte(`{"v":1}`)), false},
{configtypes.PublicationDataFormatJSONObject, nil, base64.StdEncoding.EncodeToString([]byte(`nope`)), true},
}
for i, c := range cases {
t.Run(fmt.Sprintf("%d_format=%q", i, c.format), func(t *testing.T) {
cfg := configWithNamespace("ns", "map")
cfg.Channel.Namespaces[0].PublicationDataFormat = c.format
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
node := nodeWithMapSupport(cfgContainer)
defer func() { _ = node.Shutdown(context.Background()) }()
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test"})
resp := api.MapPublish(context.Background(), &MapPublishRequest{
Channel: "ns:test", Key: "k", Data: c.data, B64Data: c.b64Data,
})
if c.wantReject {
require.Equal(t, ErrorBadRequest, resp.Error)
} else {
require.Nil(t, resp.Error)
}
})
}
}
// TestSharedPollPublishAPI_DataFormat ensures shared_poll_publish applies the
// namespace publication_data_format policy.
func TestSharedPollPublishAPI_DataFormat(t *testing.T) {
cases := []struct {
format string
data []byte
wantReject bool
}{
{configtypes.PublicationDataFormatJSONObject, []byte(`{"v":1}`), false},
{configtypes.PublicationDataFormatJSONObject, []byte(`nope`), true},
{configtypes.PublicationDataFormatJSON, []byte(`nope`), true},
{configtypes.PublicationDataFormatBinary, nil, false},
{"", nil, true}, // default format rejects empty data
}
for i, c := range cases {
t.Run(fmt.Sprintf("%d_format=%q", i, c.format), func(t *testing.T) {
cfg := configWithNamespace("ns", "shared_poll")
cfg.Channel.Namespaces[0].PublicationDataFormat = c.format
cfg.Channel.Namespaces[0].SharedPoll.PublishEnabled = true
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test"})
resp := api.SharedPollPublish(context.Background(), &SharedPollPublishRequest{
Channel: "ns:test", Key: "k", Version: 1, Data: c.data,
})
if c.wantReject {
require.Equal(t, ErrorBadRequest, resp.Error)
} else {
// The test node has no shared poll manager, so a valid payload
// gets past validation and fails later — the point is that it is
// not rejected as a bad request.
require.NotEqual(t, ErrorBadRequest, resp.Error)
}
})
}
}

314
internal/api/consuming.go Normal file
View File

@@ -0,0 +1,314 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifuge"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
// ConsumingHandlerConfig configures ConsumingHandler.
type ConsumingHandlerConfig struct {
UseOpenTelemetry bool
}
// ConsumingHandler is responsible for processing API commands from asynchronous consumers.
type ConsumingHandler struct {
node *centrifuge.Node
config ConsumingHandlerConfig
api *Executor
}
// NewConsumingHandler creates new ConsumingHandler.
func NewConsumingHandler(n *centrifuge.Node, apiExecutor *Executor, c ConsumingHandlerConfig) *ConsumingHandler {
h := &ConsumingHandler{
node: n,
config: c,
api: apiExecutor,
}
return h
}
func logNonRetryableConsumingError(err error, method string) {
log.Error().Err(err).Str("method", method).Msg("non retryable error during consuming, skip message")
}
// ErrShutdown is returned to consumers for messages received after Node shutdown
// started. Such messages must not be acknowledged: this node is no longer able to
// deliver them, and depending on the broker a publish may still return success (a
// memory broker never fails, so without this check a message would be committed
// while going nowhere). Returning an error makes consumers leave the message
// unacknowledged, so it's redelivered to another node or after restart.
//
// It wraps context.Canceled since consumers treat cancellation as a stop signal
// and give up immediately instead of retrying with backoff until the shutdown
// timeout is reached.
var ErrShutdown = fmt.Errorf("node is shutting down: %w", context.Canceled)
type Dispatcher struct {
handler *ConsumingHandler
}
func NewDispatcher(handler *ConsumingHandler) *Dispatcher {
return &Dispatcher{
handler: handler,
}
}
// shuttingDown returns true once Node shutdown started.
func (d *Dispatcher) shuttingDown() bool {
select {
case <-d.handler.node.NotifyShutdown():
return true
default:
return false
}
}
func (d *Dispatcher) Publish(ctx context.Context, req *apiproto.PublishRequest) error {
if d.shuttingDown() {
return ErrShutdown
}
resp := d.handler.api.Publish(ctx, req)
if d.handler.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && resp.Error.Code == apiproto.ErrorInternal.Code {
return resp.Error
}
if resp.Error != nil {
logNonRetryableConsumingError(resp.Error, "publish")
}
return nil
}
func (d *Dispatcher) Broadcast(ctx context.Context, req *apiproto.BroadcastRequest) error {
if d.shuttingDown() {
return ErrShutdown
}
resp := d.handler.api.Broadcast(ctx, req)
if d.handler.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && resp.Error.Code == apiproto.ErrorInternal.Code {
return resp.Error
}
if resp.Error != nil {
logNonRetryableConsumingError(resp.Error, "broadcast")
return nil
}
for _, response := range resp.Result.Responses {
if response.Error != nil && response.Error.Code == apiproto.ErrorInternal.Code {
// Any internal error in any channel response will result into a retry by a consumer.
// To prevent duplicate messages publishers may use idempotency keys.
return response.Error
}
}
return nil
}
type ConsumedPublication struct {
Data []byte
// IdempotencyKey is used to prevent duplicate messages.
IdempotencyKey string
// Delta is used to indicate that the message is a delta update.
Delta bool
// Tags are used to attach metadata to the message.
Tags map[string]string
// Version of publication.
Version uint64
// VersionEpoch of publication.
VersionEpoch string
}
func (d *Dispatcher) DispatchPublication(
ctx context.Context, channels []string, pub ConsumedPublication,
) error {
if d.shuttingDown() {
return ErrShutdown
}
if len(channels) == 0 {
return nil
}
if len(channels) == 1 {
req := &apiproto.PublishRequest{
Data: pub.Data,
Channel: channels[0],
IdempotencyKey: pub.IdempotencyKey,
Delta: pub.Delta,
Tags: pub.Tags,
Version: pub.Version,
VersionEpoch: pub.VersionEpoch,
}
return d.Publish(ctx, req)
}
req := &apiproto.BroadcastRequest{
Data: pub.Data,
Channels: channels,
IdempotencyKey: pub.IdempotencyKey,
Delta: pub.Delta,
Tags: pub.Tags,
Version: pub.Version,
VersionEpoch: pub.VersionEpoch,
}
return d.Broadcast(ctx, req)
}
// Dispatch processes commands received from asynchronous consumers.
func (d *Dispatcher) dispatchMethodPayload(ctx context.Context, method string, payload []byte) error {
switch method {
case "publish":
_, err := d.handler.handlePublish(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
return nil
case "broadcast":
// This one is special as we need to iterate over responses. Ideally we want to use
// code gen for Dispatch but need to make sure that broadcast logic is preserved.
res, err := d.handler.handleBroadcast(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
for _, resp := range res.Responses {
if resp.Error != nil && resp.Error.Code == apiproto.ErrorInternal.Code {
// Any internal error in any channel response will result into a retry by a consumer.
// To prevent duplicate messages publishers may use idempotency keys.
return resp.Error
}
}
return nil
case "subscribe":
_, err := d.handler.handleSubscribe(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
return nil
case "unsubscribe":
_, err := d.handler.handleUnsubscribe(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
return nil
case "disconnect":
_, err := d.handler.handleDisconnect(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
return nil
case "history_remove":
_, err := d.handler.handleHistoryRemove(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
return nil
case "refresh":
_, err := d.handler.handleRefresh(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
return nil
default:
// Skip unsupported.
log.Info().Msg("skip unsupported API command method")
return nil
}
}
var ErrInvalidData = errors.New("invalid data")
// JSONRawOrString can decode payload from bytes and from JSON string. This gives
// us better interoperability. For example, JSONB field is encoded as JSON string in
// Debezium PostgreSQL connector.
type JSONRawOrString json.RawMessage
func (j *JSONRawOrString) UnmarshalJSON(data []byte) error {
if len(data) > 0 && data[0] == '"' {
// Unmarshal as a string, then convert the string to json.RawMessage.
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
*j = JSONRawOrString(str)
} else {
// Unmarshal directly as json.RawMessage
*j = data
}
return nil
}
// MarshalJSON returns m as the JSON encoding of m.
func (j JSONRawOrString) MarshalJSON() ([]byte, error) {
if j == nil {
return []byte("null"), nil
}
return j, nil
}
type MethodWithRequestPayload struct {
Method string `json:"method"`
Payload JSONRawOrString `json:"payload"`
}
// DispatchCommand processes commands received from asynchronous consumers.
func (d *Dispatcher) DispatchCommand(ctx context.Context, method string, payload []byte) error {
if d.shuttingDown() {
return ErrShutdown
}
if method != "" {
// If method already known – we can skip decoding into MethodWithRequestPayload.
return d.dispatchMethodPayload(ctx, method, payload)
}
// Otherwise we expect payload to be MethodWithRequestPayload.
var e MethodWithRequestPayload
err := json.Unmarshal(payload, &e)
if err != nil {
log.Error().Err(err).Msg("skip malformed consumed message")
return nil
}
return d.dispatchMethodPayload(ctx, e.Method, e.Payload)
}

View File

@@ -0,0 +1,132 @@
// Code generated by internal/gen/api/main.go. DO NOT EDIT.
package api
import (
"context"
. "github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
func (h *ConsumingHandler) handlePublish(ctx context.Context, data []byte) (*PublishResult, error) {
req, err := requestDecoder.DecodePublish(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.Publish(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "publish", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}
func (h *ConsumingHandler) handleBroadcast(ctx context.Context, data []byte) (*BroadcastResult, error) {
req, err := requestDecoder.DecodeBroadcast(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.Broadcast(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "broadcast", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}
func (h *ConsumingHandler) handleSubscribe(ctx context.Context, data []byte) (*SubscribeResult, error) {
req, err := requestDecoder.DecodeSubscribe(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.Subscribe(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "subscribe", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}
func (h *ConsumingHandler) handleUnsubscribe(ctx context.Context, data []byte) (*UnsubscribeResult, error) {
req, err := requestDecoder.DecodeUnsubscribe(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.Unsubscribe(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "unsubscribe", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}
func (h *ConsumingHandler) handleDisconnect(ctx context.Context, data []byte) (*DisconnectResult, error) {
req, err := requestDecoder.DecodeDisconnect(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.Disconnect(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "disconnect", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}
func (h *ConsumingHandler) handleHistoryRemove(ctx context.Context, data []byte) (*HistoryRemoveResult, error) {
req, err := requestDecoder.DecodeHistoryRemove(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.HistoryRemove(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "history_remove", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}
func (h *ConsumingHandler) handleRefresh(ctx context.Context, data []byte) (*RefreshResult, error) {
req, err := requestDecoder.DecodeRefresh(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.Refresh(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "refresh", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}

View File

@@ -0,0 +1,74 @@
package api
import (
"context"
"testing"
"github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/stretchr/testify/require"
)
func TestNewConsumingHandler(t *testing.T) {
n := nodeWithMemoryEngine()
defer func() { _ = n.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
handler := NewConsumingHandler(n, NewExecutor(n, cfgContainer, nil, ExecutorConfig{
Protocol: "consumer",
UseOpenTelemetry: false,
}), ConsumingHandlerConfig{})
dispatcher := NewDispatcher(handler)
// Bad request must be just logged but no errors other than Internal Error should be returned from Dispatch.
err = dispatcher.DispatchCommand(context.Background(), "publish", []byte(`{}`))
require.NoError(t, err)
}
func TestDispatcherErrorsAfterNodeShutdown(t *testing.T) {
n := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
dispatcher := NewDispatcher(NewConsumingHandler(n, NewExecutor(n, cfgContainer, nil, ExecutorConfig{
Protocol: "consumer",
}), ConsumingHandlerConfig{}))
const payload = `{"channel": "test", "data": {}}`
// While node is running message must be dispatched successfully.
require.NoError(t, dispatcher.DispatchCommand(context.Background(), "publish", []byte(payload)))
require.NoError(t, n.Shutdown(context.Background()))
// Memory broker keeps accepting publications even after Node shutdown – without
// an explicit check a consumer would acknowledge a message which was never and
// will never be delivered.
err = dispatcher.DispatchCommand(context.Background(), "publish", []byte(payload))
require.ErrorIs(t, err, ErrShutdown)
// Consumers must stop processing instead of retrying until shutdown timeout.
require.ErrorIs(t, err, context.Canceled)
err = dispatcher.DispatchPublication(context.Background(), []string{"test"}, ConsumedPublication{
Data: []byte(`{}`),
})
require.ErrorIs(t, err, ErrShutdown)
// Typed entry points must behave the same way.
err = dispatcher.Publish(context.Background(), &apiproto.PublishRequest{
Channel: "test", Data: []byte(`{}`),
})
require.ErrorIs(t, err, ErrShutdown)
err = dispatcher.Broadcast(context.Background(), &apiproto.BroadcastRequest{
Channels: []string{"test"}, Data: []byte(`{}`),
})
require.ErrorIs(t, err, ErrShutdown)
}

77
internal/api/grpc.go Normal file
View File

@@ -0,0 +1,77 @@
package api
import (
"context"
"crypto/subtle"
. "github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifuge"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func authorize(ctx context.Context, key []byte) error {
if md, ok := metadata.FromIncomingContext(ctx); ok {
if len(md["authorization"]) > 0 && subtle.ConstantTimeCompare([]byte(md["authorization"][0]), key) == 1 {
return nil
}
}
return status.Error(codes.Unauthenticated, "unauthenticated")
}
// GRPCKeyAuth allows to set simple authentication based on string key from configuration.
// Client should provide per RPC credentials: set authorization key to metadata with value
// `apikey <KEY>`.
// Only installed when grpc_api.key is set – an unauthenticated GRPC API is the documented
// default in v6, see the note in internal/app/grpc.go before reporting it as a vulnerability.
func GRPCKeyAuth(key string) grpc.ServerOption {
authKey := []byte("apikey " + key)
return grpc.UnaryInterceptor(func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
if err := authorize(ctx, authKey); err != nil {
return nil, err
}
return handler(ctx, req)
})
}
// GRPCAPIServiceConfig for GRPC API Service.
type GRPCAPIServiceConfig struct {
UseOpenTelemetry bool
UseTransportErrorMode bool
}
// RegisterGRPCServerAPI registers GRPC API service in provided GRPC server.
func RegisterGRPCServerAPI(n *centrifuge.Node, apiExecutor *Executor, server *grpc.Server, config GRPCAPIServiceConfig) error {
RegisterCentrifugoApiServer(server, newGRPCAPIService(n, apiExecutor, config))
return nil
}
// grpcAPIService can answer on GRPC API requests.
type grpcAPIService struct {
UnimplementedCentrifugoApiServer
config GRPCAPIServiceConfig
api *Executor
}
// newGRPCAPIService creates new Service.
func newGRPCAPIService(_ *centrifuge.Node, apiExecutor *Executor, c GRPCAPIServiceConfig) *grpcAPIService {
return &grpcAPIService{
config: c,
api: apiExecutor,
}
}
func (s *grpcAPIService) useTransportErrorMode(ctx context.Context) bool {
if s.config.UseTransportErrorMode {
return true
}
if md, ok := metadata.FromIncomingContext(ctx); ok {
errorMode := md.Get("x-centrifugo-error-mode")
return len(errorMode) > 0 && errorMode[0] == "transport"
}
return false
}

View File

@@ -0,0 +1,400 @@
// Code generated by internal/gen/api/main.go. DO NOT EDIT.
package api
import (
"context"
. "github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/status"
)
// Batch ...
func (s *grpcAPIService) Batch(ctx context.Context, req *BatchRequest) (*BatchResponse, error) {
resp := s.api.Batch(ctx, req)
return resp, nil
}
// Publish ...
func (s *grpcAPIService) Publish(ctx context.Context, req *PublishRequest) (*PublishResponse, error) {
resp := s.api.Publish(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "publish", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "publish", resp.Error.Code)
}
return resp, nil
}
// Broadcast ...
func (s *grpcAPIService) Broadcast(ctx context.Context, req *BroadcastRequest) (*BroadcastResponse, error) {
resp := s.api.Broadcast(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "broadcast", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "broadcast", resp.Error.Code)
}
return resp, nil
}
// Subscribe ...
func (s *grpcAPIService) Subscribe(ctx context.Context, req *SubscribeRequest) (*SubscribeResponse, error) {
resp := s.api.Subscribe(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "subscribe", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "subscribe", resp.Error.Code)
}
return resp, nil
}
// Unsubscribe ...
func (s *grpcAPIService) Unsubscribe(ctx context.Context, req *UnsubscribeRequest) (*UnsubscribeResponse, error) {
resp := s.api.Unsubscribe(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "unsubscribe", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "unsubscribe", resp.Error.Code)
}
return resp, nil
}
// Disconnect ...
func (s *grpcAPIService) Disconnect(ctx context.Context, req *DisconnectRequest) (*DisconnectResponse, error) {
resp := s.api.Disconnect(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "disconnect", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "disconnect", resp.Error.Code)
}
return resp, nil
}
// Presence ...
func (s *grpcAPIService) Presence(ctx context.Context, req *PresenceRequest) (*PresenceResponse, error) {
resp := s.api.Presence(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "presence", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "presence", resp.Error.Code)
}
return resp, nil
}
// PresenceStats ...
func (s *grpcAPIService) PresenceStats(ctx context.Context, req *PresenceStatsRequest) (*PresenceStatsResponse, error) {
resp := s.api.PresenceStats(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "presence_stats", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "presence_stats", resp.Error.Code)
}
return resp, nil
}
// History ...
func (s *grpcAPIService) History(ctx context.Context, req *HistoryRequest) (*HistoryResponse, error) {
resp := s.api.History(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "history", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "history", resp.Error.Code)
}
return resp, nil
}
// HistoryRemove ...
func (s *grpcAPIService) HistoryRemove(ctx context.Context, req *HistoryRemoveRequest) (*HistoryRemoveResponse, error) {
resp := s.api.HistoryRemove(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "history_remove", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "history_remove", resp.Error.Code)
}
return resp, nil
}
// Info ...
func (s *grpcAPIService) Info(ctx context.Context, req *InfoRequest) (*InfoResponse, error) {
resp := s.api.Info(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "info", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "info", resp.Error.Code)
}
return resp, nil
}
// RPC ...
func (s *grpcAPIService) RPC(ctx context.Context, req *RPCRequest) (*RPCResponse, error) {
resp := s.api.RPC(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "rpc", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "rpc", resp.Error.Code)
}
return resp, nil
}
// Refresh ...
func (s *grpcAPIService) Refresh(ctx context.Context, req *RefreshRequest) (*RefreshResponse, error) {
resp := s.api.Refresh(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "refresh", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "refresh", resp.Error.Code)
}
return resp, nil
}
// Channels ...
func (s *grpcAPIService) Channels(ctx context.Context, req *ChannelsRequest) (*ChannelsResponse, error) {
resp := s.api.Channels(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "channels", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "channels", resp.Error.Code)
}
return resp, nil
}
// MapPublish ...
func (s *grpcAPIService) MapPublish(ctx context.Context, req *MapPublishRequest) (*MapPublishResponse, error) {
resp := s.api.MapPublish(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "map_publish", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_publish", resp.Error.Code)
}
return resp, nil
}
// MapRemove ...
func (s *grpcAPIService) MapRemove(ctx context.Context, req *MapRemoveRequest) (*MapRemoveResponse, error) {
resp := s.api.MapRemove(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "map_remove", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_remove", resp.Error.Code)
}
return resp, nil
}
// MapReadState ...
func (s *grpcAPIService) MapReadState(ctx context.Context, req *MapReadStateRequest) (*MapReadStateResponse, error) {
resp := s.api.MapReadState(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "map_read_state", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_read_state", resp.Error.Code)
}
return resp, nil
}
// MapReadStream ...
func (s *grpcAPIService) MapReadStream(ctx context.Context, req *MapReadStreamRequest) (*MapReadStreamResponse, error) {
resp := s.api.MapReadStream(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "map_read_stream", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_read_stream", resp.Error.Code)
}
return resp, nil
}
// MapStats ...
func (s *grpcAPIService) MapStats(ctx context.Context, req *MapStatsRequest) (*MapStatsResponse, error) {
resp := s.api.MapStats(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "map_stats", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_stats", resp.Error.Code)
}
return resp, nil
}
// MapClear ...
func (s *grpcAPIService) MapClear(ctx context.Context, req *MapClearRequest) (*MapClearResponse, error) {
resp := s.api.MapClear(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "map_clear", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_clear", resp.Error.Code)
}
return resp, nil
}
// SharedPollPublish ...
func (s *grpcAPIService) SharedPollPublish(ctx context.Context, req *SharedPollPublishRequest) (*SharedPollPublishResponse, error) {
resp := s.api.SharedPollPublish(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "shared_poll_publish", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "shared_poll_publish", resp.Error.Code)
}
return resp, nil
}

35
internal/api/grpc_test.go Normal file
View File

@@ -0,0 +1,35 @@
package api
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/metadata"
)
func TestGRPC_Authorize_Unauthenticated(t *testing.T) {
err := authorize(context.Background(), []byte("apikey xxx"))
require.Error(t, err)
md := metadata.New(map[string]string{})
ctx := metadata.NewIncomingContext(context.Background(), md)
err = authorize(ctx, []byte("apikey xxx"))
require.Error(t, err)
md = metadata.New(map[string]string{
"authorization": "apikey yyy",
})
ctx = metadata.NewIncomingContext(context.Background(), md)
err = authorize(ctx, []byte("apikey xxx"))
require.Error(t, err)
}
func TestGRPC_Authorize_OK(t *testing.T) {
md := metadata.New(map[string]string{
"authorization": "apikey xxx",
})
ctx := metadata.NewIncomingContext(context.Background(), md)
err := authorize(ctx, []byte("apikey xxx"))
require.NoError(t, err)
}

95
internal/api/handler.go Normal file
View File

@@ -0,0 +1,95 @@
package api
import (
"net/http"
. "github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifuge"
"github.com/rs/zerolog/log"
)
// Config configures Handler.
type Config struct {
UseOpenTelemetry bool
UseTransportErrorMode bool
}
// Handler is responsible for processing API commands over HTTP.
type Handler struct {
mux *http.ServeMux
node *centrifuge.Node
config Config
api *Executor
}
var requestDecoder = NewJSONRequestDecoder()
var responseEncoder = NewJSONResponseEncoder()
// NewHandler creates new Handler.
func NewHandler(n *centrifuge.Node, apiExecutor *Executor, c Config) *Handler {
m := new(http.ServeMux)
h := &Handler{
mux: m,
node: n,
config: c,
api: apiExecutor,
}
return h
}
func (s *Handler) Routes() map[string]http.HandlerFunc {
return map[string]http.HandlerFunc{
"/batch": s.handleBatch,
"/publish": s.handlePublish,
"/broadcast": s.handleBroadcast,
"/subscribe": s.handleSubscribe,
"/unsubscribe": s.handleUnsubscribe,
"/disconnect": s.handleDisconnect,
"/presence": s.handlePresence,
"/presence_stats": s.handlePresenceStats,
"/history": s.handleHistory,
"/history_remove": s.handleHistoryRemove,
"/info": s.handleInfo,
"/rpc": s.handleRPC,
"/refresh": s.handleRefresh,
"/channels": s.handleChannels,
"/map_publish": s.handleMapPublish,
"/map_remove": s.handleMapRemove,
"/map_read_state": s.handleMapReadState,
"/map_read_stream": s.handleMapReadStream,
"/map_stats": s.handleMapStats,
"/map_clear": s.handleMapClear,
"/shared_poll_publish": s.handleSharedPollPublish,
}
}
func (s *Handler) handleReadDataError(r *http.Request, w http.ResponseWriter, err error) {
log.Error().Err(err).Str("path", r.URL.Path).Msg("error reading API request body")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
func (s *Handler) handleUnmarshalError(r *http.Request, w http.ResponseWriter, err error) {
log.Error().Err(err).Str("path", r.URL.Path).Msg("error decoding API request body")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
}
func (s *Handler) handleMarshalError(r *http.Request, w http.ResponseWriter, err error) {
log.Error().Err(err).Str("path", r.URL.Path).Msg("error encoding API response")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
func (s *Handler) writeJson(w http.ResponseWriter, data []byte) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
}
func (s *Handler) writeJsonCustomStatus(w http.ResponseWriter, statusCode int, data []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_, _ = w.Write(data)
}
func (s *Handler) useTransportErrorMode(r *http.Request) bool {
return s.config.UseTransportErrorMode || r.Header.Get("X-Centrifugo-Error-Mode") == "transport"
}

881
internal/api/handler_gen.go Normal file
View File

@@ -0,0 +1,881 @@
// Code generated by internal/gen/api/main.go. DO NOT EDIT.
package api
import (
"io"
"net/http"
. "github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
func (s *Handler) handleBatch(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "batch", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeBatch(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "batch", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Batch(r.Context(), req)
data, err = responseEncoder.EncodeBatch(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "batch", "marshal")
s.handleMarshalError(r, w, err)
return
}
s.writeJson(w, data)
}
func (s *Handler) handlePublish(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "publish", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodePublish(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "publish", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Publish(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "publish", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodePublish(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "publish", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "publish", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleBroadcast(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "broadcast", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeBroadcast(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "broadcast", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Broadcast(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "broadcast", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeBroadcast(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "broadcast", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "broadcast", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleSubscribe(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "subscribe", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeSubscribe(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "subscribe", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Subscribe(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "subscribe", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeSubscribe(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "subscribe", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "subscribe", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleUnsubscribe(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "unsubscribe", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeUnsubscribe(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "unsubscribe", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Unsubscribe(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "unsubscribe", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeUnsubscribe(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "unsubscribe", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "unsubscribe", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleDisconnect(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "disconnect", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeDisconnect(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "disconnect", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Disconnect(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "disconnect", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeDisconnect(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "disconnect", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "disconnect", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handlePresence(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "presence", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodePresence(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "presence", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Presence(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "presence", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodePresence(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "presence", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "presence", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handlePresenceStats(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "presence_stats", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodePresenceStats(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "presence_stats", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.PresenceStats(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "presence_stats", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodePresenceStats(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "presence_stats", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "presence_stats", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleHistory(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "history", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeHistory(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "history", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.History(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "history", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeHistory(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "history", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "history", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleHistoryRemove(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "history_remove", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeHistoryRemove(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "history_remove", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.HistoryRemove(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "history_remove", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeHistoryRemove(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "history_remove", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "history_remove", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleInfo(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "info", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeInfo(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "info", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Info(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "info", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeInfo(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "info", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "info", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleRPC(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "rpc", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeRPC(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "rpc", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.RPC(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "rpc", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeRPC(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "rpc", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "rpc", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "refresh", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeRefresh(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "refresh", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Refresh(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "refresh", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeRefresh(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "refresh", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "refresh", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleChannels(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "channels", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeChannels(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "channels", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Channels(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "channels", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeChannels(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "channels", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "channels", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleMapPublish(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_publish", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeMapPublish(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_publish", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.MapPublish(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "map_publish", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeMapPublish(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_publish", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_publish", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleMapRemove(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_remove", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeMapRemove(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_remove", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.MapRemove(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "map_remove", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeMapRemove(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_remove", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_remove", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleMapReadState(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_read_state", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeMapReadState(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_read_state", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.MapReadState(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "map_read_state", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeMapReadState(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_read_state", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_read_state", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleMapReadStream(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_read_stream", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeMapReadStream(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_read_stream", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.MapReadStream(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "map_read_stream", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeMapReadStream(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_read_stream", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_read_stream", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleMapStats(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_stats", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeMapStats(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_stats", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.MapStats(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "map_stats", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeMapStats(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_stats", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_stats", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleMapClear(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_clear", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeMapClear(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_clear", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.MapClear(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "map_clear", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeMapClear(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_clear", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_clear", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleSharedPollPublish(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "shared_poll_publish", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeSharedPollPublish(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "shared_poll_publish", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.SharedPollPublish(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "shared_poll_publish", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeSharedPollPublish(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "shared_poll_publish", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "shared_poll_publish", resp.Error.Code)
}
s.writeJson(w, data)
}

View File

@@ -0,0 +1,774 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"sync"
"github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
type LegacyCommand struct {
Id uint32 `json:"id,omitempty"`
Method CommandMethodType `json:"method,omitempty"`
Params apiproto.Raw `json:"params,omitempty"`
}
type LegacyReply struct {
Id uint32 `json:"id,omitempty"`
Error *apiproto.Error `json:"error,omitempty"`
Result apiproto.Raw `json:"result,omitempty"`
}
type CommandMethodType int32
// UnmarshalJSON helps to unmarshal command method when set as string.
func (m *CommandMethodType) UnmarshalJSON(data []byte) error {
if len(data) > 2 {
if v, ok := Command_MethodType_value[strings.ToUpper(string(data[1:len(data)-1]))]; ok {
*m = CommandMethodType(v)
return nil
}
}
val, err := strconv.Atoi(string(data))
if err != nil {
return err
}
*m = CommandMethodType(val)
return nil
}
// GetParamsDecoder ...
func GetParamsDecoder() apiproto.RequestDecoder {
return apiproto.NewJSONRequestDecoder()
}
// PutParamsDecoder ...
func PutParamsDecoder(_ apiproto.RequestDecoder) {}
// GetResultEncoder ...
func GetResultEncoder() apiproto.ResultEncoder {
return apiproto.NewJSONResultEncoder()
}
// PutResultEncoder ...
func PutResultEncoder(_ apiproto.ResultEncoder) {}
var (
jsonReplyEncoderPool sync.Pool
)
// ReplyEncoder ...
type ReplyEncoder interface {
Reset()
Encode(reply *LegacyReply) error
Finish() []byte
}
var _ ReplyEncoder = (*JSONReplyEncoder)(nil)
// JSONReplyEncoder ...
type JSONReplyEncoder struct {
count int
buffer bytes.Buffer
}
// NewJSONReplyEncoder ...
func NewJSONReplyEncoder() *JSONReplyEncoder {
return &JSONReplyEncoder{}
}
// Reset ...
func (e *JSONReplyEncoder) Reset() {
e.count = 0
e.buffer.Reset()
}
// Encode ...
func (e *JSONReplyEncoder) Encode(r *LegacyReply) error {
if e.count > 0 {
e.buffer.WriteString("\n")
}
data, err := json.Marshal(r)
if err != nil {
return err
}
e.buffer.Write(data)
e.count++
return nil
}
// Finish ...
func (e *JSONReplyEncoder) Finish() []byte {
data := e.buffer.Bytes()
dataCopy := make([]byte, len(data))
copy(dataCopy, data)
return dataCopy
}
// GetReplyEncoder ...
func GetReplyEncoder() ReplyEncoder {
e := jsonReplyEncoderPool.Get()
if e == nil {
return NewJSONReplyEncoder()
}
return e.(ReplyEncoder)
}
// PutReplyEncoder ...
func PutReplyEncoder(e ReplyEncoder) {
e.Reset()
jsonReplyEncoderPool.Put(e)
}
// CommandDecoder ...
type CommandDecoder interface {
Reset([]byte)
Decode() (*LegacyCommand, error)
}
var jsonCommandDecoderPool sync.Pool
var _ CommandDecoder = (*JSONCommandDecoder)(nil)
// GetCommandDecoder ...
func GetCommandDecoder(data []byte) CommandDecoder {
e := jsonCommandDecoderPool.Get()
if e == nil {
return NewJSONCommandDecoder(data)
}
decoder := e.(CommandDecoder)
decoder.Reset(data)
return decoder
}
// PutCommandDecoder ...
func PutCommandDecoder(d CommandDecoder) {
jsonCommandDecoderPool.Put(d)
}
// JSONCommandDecoder ...
type JSONCommandDecoder struct {
decoder *json.Decoder
}
// NewJSONCommandDecoder ...
func NewJSONCommandDecoder(data []byte) *JSONCommandDecoder {
return &JSONCommandDecoder{
decoder: json.NewDecoder(bytes.NewReader(data)),
}
}
// Reset ...
func (d *JSONCommandDecoder) Reset(data []byte) {
d.decoder = json.NewDecoder(bytes.NewReader(data))
}
// Decode ...
func (d *JSONCommandDecoder) Decode() (*LegacyCommand, error) {
var c LegacyCommand
err := d.decoder.Decode(&c)
if err != nil {
return nil, err
}
return &c, nil
}
// OldRoute handles all methods inside one /api handler.
// The plan is to remove it in Centrifugo v7.
func (s *Handler) OldRoute() http.HandlerFunc {
return s.handleAPI
}
func (s *Handler) handleAPI(w http.ResponseWriter, r *http.Request) {
select {
case <-s.node.NotifyShutdown():
w.WriteHeader(http.StatusServiceUnavailable)
return
default:
}
var data []byte
var err error
data, err = io.ReadAll(r.Body)
if err != nil {
log.Error().Err(err).Msg("error reading API request body")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if len(data) == 0 {
log.Error().Err(errors.New("no data in API request")).Msg("bad request")
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
encoder := GetReplyEncoder()
defer PutReplyEncoder(encoder)
decoder := GetCommandDecoder(data)
defer PutCommandDecoder(decoder)
for {
command, decodeErr := decoder.Decode()
if decodeErr != nil && decodeErr != io.EOF {
log.Error().Err(decodeErr).Msg("error decoding API data")
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if command != nil {
if s.config.UseOpenTelemetry {
span := trace.SpanFromContext(r.Context())
span.SetAttributes(attribute.String("method", Command_MethodType_name[int32(command.Method)]))
}
rep, err := s.handleAPICommand(r.Context(), command)
if err != nil {
if s.config.UseOpenTelemetry {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, err.Error())
}
log.Error().Err(err).Msg("error handling API command")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if s.config.UseOpenTelemetry && rep.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, rep.Error.Error())
}
err = encoder.Encode(rep)
if err != nil {
log.Error().Err(err).Msg("error encoding API reply")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
if decodeErr == io.EOF {
break
}
}
s.writeJson(w, encoder.Finish())
}
const (
Command_PUBLISH CommandMethodType = 0
Command_BROADCAST CommandMethodType = 1
Command_UNSUBSCRIBE CommandMethodType = 2
Command_DISCONNECT CommandMethodType = 3
Command_PRESENCE CommandMethodType = 4
Command_PRESENCE_STATS CommandMethodType = 5
Command_HISTORY CommandMethodType = 6
Command_HISTORY_REMOVE CommandMethodType = 7
Command_CHANNELS CommandMethodType = 8
Command_INFO CommandMethodType = 9
Command_RPC CommandMethodType = 10
Command_SUBSCRIBE CommandMethodType = 11
Command_REFRESH CommandMethodType = 12
Command_CONNECTIONS CommandMethodType = 14
Command_UPDATE_USER_STATUS CommandMethodType = 15
Command_GET_USER_STATUS CommandMethodType = 16
Command_DELETE_USER_STATUS CommandMethodType = 17
Command_BLOCK_USER CommandMethodType = 18
Command_UNBLOCK_USER CommandMethodType = 19
Command_REVOKE_TOKEN CommandMethodType = 20
Command_INVALIDATE_USER_TOKENS CommandMethodType = 21
Command_DEVICE_REGISTER CommandMethodType = 22
Command_DEVICE_UPDATE CommandMethodType = 23
Command_DEVICE_REMOVE CommandMethodType = 24
Command_DEVICE_LIST CommandMethodType = 25
Command_DEVICE_TOPIC_LIST CommandMethodType = 26
Command_DEVICE_TOPIC_UPDATE CommandMethodType = 27
Command_USER_TOPIC_LIST CommandMethodType = 28
Command_USER_TOPIC_UPDATE CommandMethodType = 29
Command_SEND_PUSH_NOTIFICATION CommandMethodType = 30
Command_UPDATE_PUSH_STATUS CommandMethodType = 31
Command_CANCEL_PUSH CommandMethodType = 32
Command_MAP_PUBLISH CommandMethodType = 33
Command_MAP_REMOVE CommandMethodType = 34
Command_MAP_READ_STATE CommandMethodType = 35
Command_MAP_READ_STREAM CommandMethodType = 36
Command_MAP_STATS CommandMethodType = 37
Command_MAP_CLEAR CommandMethodType = 38
Command_SHARED_POLL_PUBLISH CommandMethodType = 39
)
var Command_MethodType_value = map[string]int32{
"PUBLISH": 0,
"BROADCAST": 1,
"UNSUBSCRIBE": 2,
"DISCONNECT": 3,
"PRESENCE": 4,
"PRESENCE_STATS": 5,
"HISTORY": 6,
"HISTORY_REMOVE": 7,
"CHANNELS": 8,
"INFO": 9,
"RPC": 10,
"SUBSCRIBE": 11,
"REFRESH": 12,
"CONNECTIONS": 14,
"UPDATE_USER_STATUS": 15,
"GET_USER_STATUS": 16,
"DELETE_USER_STATUS": 17,
"BLOCK_USER": 18,
"UNBLOCK_USER": 19,
"REVOKE_TOKEN": 20,
"INVALIDATE_USER_TOKENS": 21,
"DEVICE_REGISTER": 22,
"DEVICE_UPDATE": 23,
"DEVICE_REMOVE": 24,
"DEVICE_LIST": 25,
"DEVICE_TOPIC_LIST": 26,
"DEVICE_TOPIC_UPDATE": 27,
"USER_TOPIC_LIST": 28,
"USER_TOPIC_UPDATE": 29,
"SEND_PUSH_NOTIFICATION": 30,
"UPDATE_PUSH_STATUS": 31,
"CANCEL_PUSH": 32,
"MAP_PUBLISH": 33,
"MAP_REMOVE": 34,
"MAP_READ_STATE": 35,
"MAP_READ_STREAM": 36,
"MAP_STATS": 37,
"MAP_CLEAR": 38,
"SHARED_POLL_PUBLISH": 39,
}
var (
Command_MethodType_name = map[int32]string{
0: "PUBLISH",
1: "BROADCAST",
2: "UNSUBSCRIBE",
3: "DISCONNECT",
4: "PRESENCE",
5: "PRESENCE_STATS",
6: "HISTORY",
7: "HISTORY_REMOVE",
8: "CHANNELS",
9: "INFO",
10: "RPC",
11: "SUBSCRIBE",
12: "REFRESH",
14: "CONNECTIONS",
15: "UPDATE_USER_STATUS",
16: "GET_USER_STATUS",
17: "DELETE_USER_STATUS",
18: "BLOCK_USER",
19: "UNBLOCK_USER",
20: "REVOKE_TOKEN",
21: "INVALIDATE_USER_TOKENS",
22: "DEVICE_REGISTER",
23: "DEVICE_UPDATE",
24: "DEVICE_REMOVE",
25: "DEVICE_LIST",
26: "DEVICE_TOPIC_LIST",
27: "DEVICE_TOPIC_UPDATE",
28: "USER_TOPIC_LIST",
29: "USER_TOPIC_UPDATE",
30: "SEND_PUSH_NOTIFICATION",
31: "UPDATE_PUSH_STATUS",
32: "CANCEL_PUSH",
33: "MAP_PUBLISH",
34: "MAP_REMOVE",
35: "MAP_READ_STATE",
36: "MAP_READ_STREAM",
37: "MAP_STATS",
38: "MAP_CLEAR",
39: "SHARED_POLL_PUBLISH",
}
)
func (s *Handler) handleAPICommand(ctx context.Context, cmd *LegacyCommand) (*LegacyReply, error) {
method := cmd.Method
params := cmd.Params
rep := &LegacyReply{
Id: cmd.Id,
}
var replyRes apiproto.Raw
decoder := GetParamsDecoder()
defer PutParamsDecoder(decoder)
encoder := GetResultEncoder()
defer PutResultEncoder(encoder)
switch method {
case Command_PUBLISH:
cmd, err := decoder.DecodePublish(params)
if err != nil {
log.Error().Err(err).Msg("error decoding publish params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Publish(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodePublish(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_BROADCAST:
cmd, err := decoder.DecodeBroadcast(params)
if err != nil {
log.Error().Err(err).Msg("error decoding broadcast params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Broadcast(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeBroadcast(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_SUBSCRIBE:
cmd, err := decoder.DecodeSubscribe(params)
if err != nil {
log.Error().Err(err).Msg("error decoding subscribe params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Subscribe(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeSubscribe(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_UNSUBSCRIBE:
cmd, err := decoder.DecodeUnsubscribe(params)
if err != nil {
log.Error().Err(err).Msg("error decoding unsubscribe params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Unsubscribe(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeUnsubscribe(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_DISCONNECT:
cmd, err := decoder.DecodeDisconnect(params)
if err != nil {
log.Error().Err(err).Msg("error decoding disconnect params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Disconnect(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeDisconnect(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_PRESENCE:
cmd, err := decoder.DecodePresence(params)
if err != nil {
log.Error().Err(err).Msg("error decoding presence params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Presence(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodePresence(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_PRESENCE_STATS:
cmd, err := decoder.DecodePresenceStats(params)
if err != nil {
log.Error().Err(err).Msg("error decoding presence stats params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.PresenceStats(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodePresenceStats(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_HISTORY:
cmd, err := decoder.DecodeHistory(params)
if err != nil {
log.Error().Err(err).Msg("error decoding history params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.History(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeHistory(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_HISTORY_REMOVE:
cmd, err := decoder.DecodeHistoryRemove(params)
if err != nil {
log.Error().Err(err).Msg("error decoding history remove params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.HistoryRemove(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeHistoryRemove(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_INFO:
resp := s.api.Info(ctx, &apiproto.InfoRequest{})
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
var err error
replyRes, err = encoder.EncodeInfo(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_RPC:
cmd, err := decoder.DecodeRPC(params)
if err != nil {
log.Error().Err(err).Msg("error decoding rpc params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.RPC(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeRPC(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_REFRESH:
cmd, err := decoder.DecodeRefresh(params)
if err != nil {
log.Error().Err(err).Msg("error decoding refresh params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Refresh(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeRefresh(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_CHANNELS:
cmd, err := decoder.DecodeChannels(params)
if err != nil {
log.Error().Err(err).Msg("error decoding channels params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Channels(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeChannels(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_MAP_PUBLISH:
cmd, err := decoder.DecodeMapPublish(params)
if err != nil {
log.Error().Err(err).Msg("error decoding map publish params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.MapPublish(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeMapPublish(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_MAP_REMOVE:
cmd, err := decoder.DecodeMapRemove(params)
if err != nil {
log.Error().Err(err).Msg("error decoding map remove params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.MapRemove(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeMapRemove(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_MAP_READ_STATE:
cmd, err := decoder.DecodeMapReadState(params)
if err != nil {
log.Error().Err(err).Msg("error decoding map read state params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.MapReadState(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeMapReadState(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_MAP_READ_STREAM:
cmd, err := decoder.DecodeMapReadStream(params)
if err != nil {
log.Error().Err(err).Msg("error decoding map read stream params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.MapReadStream(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeMapReadStream(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_MAP_STATS:
cmd, err := decoder.DecodeMapStats(params)
if err != nil {
log.Error().Err(err).Msg("error decoding map stats params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.MapStats(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeMapStats(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_MAP_CLEAR:
cmd, err := decoder.DecodeMapClear(params)
if err != nil {
log.Error().Err(err).Msg("error decoding map clear params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.MapClear(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeMapClear(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_SHARED_POLL_PUBLISH:
cmd, err := decoder.DecodeSharedPollPublish(params)
if err != nil {
log.Error().Err(err).Msg("error decoding shared poll publish params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.SharedPollPublish(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeSharedPollPublish(resp.Result)
if err != nil {
return nil, err
}
}
}
default:
rep.Error = apiproto.ErrorNotFound
}
if replyRes != nil {
rep.Result = replyRes
}
return rep, nil
}

View File

@@ -0,0 +1,106 @@
package api
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/stretchr/testify/require"
)
func TestAPIHandler(t *testing.T) {
n := nodeWithMemoryEngine()
defer func() { _ = n.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
apiExecutor := NewExecutor(n, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
mux := http.NewServeMux()
apiHandler := NewHandler(n, apiExecutor, Config{})
mux.Handle("/api", apiHandler.OldRoute())
for path, handler := range apiHandler.Routes() {
handlePath := "/api" + path
mux.Handle(handlePath, handler)
}
server := httptest.NewServer(mux)
defer server.Close()
// nil body.
req, _ := http.NewRequest("POST", server.URL+"/api", nil)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusBadRequest)
_ = resp.Body.Close()
// empty body.
req, _ = http.NewRequest("POST", server.URL+"/api", strings.NewReader(""))
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusBadRequest)
_ = resp.Body.Close()
// valid JSON request.
data := `{"method":"publish","params":{"channel": "test", "data":{}}}`
req, _ = http.NewRequest("POST", server.URL+"/api", bytes.NewBuffer([]byte(data)))
req.Header.Add("Content-Type", "application/json")
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusOK)
_ = resp.Body.Close()
// valid JSON request to special method route
data = `{"channel": "test", "data":{}}`
req, _ = http.NewRequest("POST", server.URL+"/api/publish", bytes.NewBuffer([]byte(data)))
req.Header.Add("Content-Type", "application/json")
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusOK)
_ = resp.Body.Close()
// request with unknown method.
data = `{"method":"unknown","params":{"channel": "test", "data":{}}}`
req, _ = http.NewRequest("POST", server.URL+"/api", bytes.NewBuffer([]byte(data)))
req.Header.Add("Content-Type", "application/json")
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusBadRequest)
_ = resp.Body.Close()
}
func BenchmarkAPIHandler(b *testing.B) {
n := nodeWithMemoryEngine()
defer func() { _ = n.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfg.Client.Insecure = true
cfgContainer, err := config.NewContainer(cfg)
require.NoError(b, err)
handler := NewHandler(n, NewExecutor(n, cfgContainer, nil, ExecutorConfig{
Protocol: "http",
UseOpenTelemetry: false,
}), Config{})
payload := []byte(`{"method": "publish", "params": {"channel": "index", "data": 1}}`)
b.ReportAllocs()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
request, _ := http.NewRequest(http.MethodPost, "/api", bytes.NewReader(payload))
recorder := httptest.NewRecorder()
handler.handlePublish(recorder, request)
if recorder.Code != http.StatusOK {
b.Fatalf("unexpected status code %d", recorder.Code)
}
}
})
}

11383
internal/apiproto/api.pb.go Normal file

File diff suppressed because it is too large Load Diff

1070
internal/apiproto/api.proto Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
package apiproto
// RequestDecoder ...
type RequestDecoder interface {
DecodeBatch([]byte) (*BatchRequest, error)
DecodePublish([]byte) (*PublishRequest, error)
DecodeBroadcast([]byte) (*BroadcastRequest, error)
DecodeSubscribe([]byte) (*SubscribeRequest, error)
DecodeUnsubscribe([]byte) (*UnsubscribeRequest, error)
DecodeDisconnect([]byte) (*DisconnectRequest, error)
DecodePresence([]byte) (*PresenceRequest, error)
DecodePresenceStats([]byte) (*PresenceStatsRequest, error)
DecodeHistory([]byte) (*HistoryRequest, error)
DecodeHistoryRemove([]byte) (*HistoryRemoveRequest, error)
DecodeInfo([]byte) (*InfoRequest, error)
DecodeRPC([]byte) (*RPCRequest, error)
DecodeRefresh([]byte) (*RefreshRequest, error)
DecodeChannels([]byte) (*ChannelsRequest, error)
DecodeMapPublish([]byte) (*MapPublishRequest, error)
DecodeMapRemove([]byte) (*MapRemoveRequest, error)
DecodeMapReadState([]byte) (*MapReadStateRequest, error)
DecodeMapReadStream([]byte) (*MapReadStreamRequest, error)
DecodeMapStats([]byte) (*MapStatsRequest, error)
DecodeMapClear([]byte) (*MapClearRequest, error)
DecodeSharedPollPublish([]byte) (*SharedPollPublishRequest, error)
}

View File

@@ -0,0 +1,225 @@
// Code generated by internal/gen/api/main.go. DO NOT EDIT.
package apiproto
import "encoding/json"
var _ RequestDecoder = (*JSONRequestDecoder)(nil)
// JSONRequestDecoder ...
type JSONRequestDecoder struct{}
// NewJSONRequestDecoder ...
func NewJSONRequestDecoder() *JSONRequestDecoder {
return &JSONRequestDecoder{}
}
// DecodeBatch ...
func (d *JSONRequestDecoder) DecodeBatch(data []byte) (*BatchRequest, error) {
var p BatchRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodePublish ...
func (d *JSONRequestDecoder) DecodePublish(data []byte) (*PublishRequest, error) {
var p PublishRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeBroadcast ...
func (d *JSONRequestDecoder) DecodeBroadcast(data []byte) (*BroadcastRequest, error) {
var p BroadcastRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeSubscribe ...
func (d *JSONRequestDecoder) DecodeSubscribe(data []byte) (*SubscribeRequest, error) {
var p SubscribeRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeUnsubscribe ...
func (d *JSONRequestDecoder) DecodeUnsubscribe(data []byte) (*UnsubscribeRequest, error) {
var p UnsubscribeRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeDisconnect ...
func (d *JSONRequestDecoder) DecodeDisconnect(data []byte) (*DisconnectRequest, error) {
var p DisconnectRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodePresence ...
func (d *JSONRequestDecoder) DecodePresence(data []byte) (*PresenceRequest, error) {
var p PresenceRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodePresenceStats ...
func (d *JSONRequestDecoder) DecodePresenceStats(data []byte) (*PresenceStatsRequest, error) {
var p PresenceStatsRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeHistory ...
func (d *JSONRequestDecoder) DecodeHistory(data []byte) (*HistoryRequest, error) {
var p HistoryRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeHistoryRemove ...
func (d *JSONRequestDecoder) DecodeHistoryRemove(data []byte) (*HistoryRemoveRequest, error) {
var p HistoryRemoveRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeInfo ...
func (d *JSONRequestDecoder) DecodeInfo(data []byte) (*InfoRequest, error) {
var p InfoRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeRPC ...
func (d *JSONRequestDecoder) DecodeRPC(data []byte) (*RPCRequest, error) {
var p RPCRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeRefresh ...
func (d *JSONRequestDecoder) DecodeRefresh(data []byte) (*RefreshRequest, error) {
var p RefreshRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeChannels ...
func (d *JSONRequestDecoder) DecodeChannels(data []byte) (*ChannelsRequest, error) {
var p ChannelsRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeMapPublish ...
func (d *JSONRequestDecoder) DecodeMapPublish(data []byte) (*MapPublishRequest, error) {
var p MapPublishRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeMapRemove ...
func (d *JSONRequestDecoder) DecodeMapRemove(data []byte) (*MapRemoveRequest, error) {
var p MapRemoveRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeMapReadState ...
func (d *JSONRequestDecoder) DecodeMapReadState(data []byte) (*MapReadStateRequest, error) {
var p MapReadStateRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeMapReadStream ...
func (d *JSONRequestDecoder) DecodeMapReadStream(data []byte) (*MapReadStreamRequest, error) {
var p MapReadStreamRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeMapStats ...
func (d *JSONRequestDecoder) DecodeMapStats(data []byte) (*MapStatsRequest, error) {
var p MapStatsRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeMapClear ...
func (d *JSONRequestDecoder) DecodeMapClear(data []byte) (*MapClearRequest, error) {
var p MapClearRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeSharedPollPublish ...
func (d *JSONRequestDecoder) DecodeSharedPollPublish(data []byte) (*SharedPollPublishRequest, error) {
var p SharedPollPublishRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}

View File

@@ -0,0 +1,25 @@
package apiproto
// ResultEncoder ...
type ResultEncoder interface {
EncodePublish(*PublishResult) ([]byte, error)
EncodeBroadcast(*BroadcastResult) ([]byte, error)
EncodeSubscribe(*SubscribeResult) ([]byte, error)
EncodeUnsubscribe(*UnsubscribeResult) ([]byte, error)
EncodeDisconnect(*DisconnectResult) ([]byte, error)
EncodePresence(*PresenceResult) ([]byte, error)
EncodePresenceStats(*PresenceStatsResult) ([]byte, error)
EncodeHistory(*HistoryResult) ([]byte, error)
EncodeHistoryRemove(*HistoryRemoveResult) ([]byte, error)
EncodeInfo(*InfoResult) ([]byte, error)
EncodeRPC(*RPCResult) ([]byte, error)
EncodeRefresh(*RefreshResult) ([]byte, error)
EncodeChannels(*ChannelsResult) ([]byte, error)
EncodeMapPublish(*MapPublishResult) ([]byte, error)
EncodeMapRemove(*MapRemoveResult) ([]byte, error)
EncodeMapReadState(*MapReadStateResult) ([]byte, error)
EncodeMapReadStream(*MapReadStreamResult) ([]byte, error)
EncodeMapStats(*MapStatsResult) ([]byte, error)
EncodeMapClear(*MapClearResult) ([]byte, error)
EncodeSharedPollPublish(*SharedPollPublishResult) ([]byte, error)
}

View File

@@ -0,0 +1,7 @@
package apiproto
import "encoding/json"
func EncodeError(err *Error) ([]byte, error) {
return json.Marshal(err)
}

View File

@@ -0,0 +1,117 @@
// Code generated by internal/gen/api/main.go. DO NOT EDIT.
package apiproto
import "encoding/json"
// JSONResponseEncoder ...
type JSONResponseEncoder struct{}
func NewJSONResponseEncoder() *JSONResponseEncoder {
return &JSONResponseEncoder{}
}
// EncodeBatch ...
func (e *JSONResponseEncoder) EncodeBatch(response *BatchResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodePublish ...
func (e *JSONResponseEncoder) EncodePublish(response *PublishResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeBroadcast ...
func (e *JSONResponseEncoder) EncodeBroadcast(response *BroadcastResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeSubscribe ...
func (e *JSONResponseEncoder) EncodeSubscribe(response *SubscribeResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeUnsubscribe ...
func (e *JSONResponseEncoder) EncodeUnsubscribe(response *UnsubscribeResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeDisconnect ...
func (e *JSONResponseEncoder) EncodeDisconnect(response *DisconnectResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodePresence ...
func (e *JSONResponseEncoder) EncodePresence(response *PresenceResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodePresenceStats ...
func (e *JSONResponseEncoder) EncodePresenceStats(response *PresenceStatsResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeHistory ...
func (e *JSONResponseEncoder) EncodeHistory(response *HistoryResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeHistoryRemove ...
func (e *JSONResponseEncoder) EncodeHistoryRemove(response *HistoryRemoveResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeInfo ...
func (e *JSONResponseEncoder) EncodeInfo(response *InfoResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeRPC ...
func (e *JSONResponseEncoder) EncodeRPC(response *RPCResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeRefresh ...
func (e *JSONResponseEncoder) EncodeRefresh(response *RefreshResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeChannels ...
func (e *JSONResponseEncoder) EncodeChannels(response *ChannelsResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeMapPublish ...
func (e *JSONResponseEncoder) EncodeMapPublish(response *MapPublishResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeMapRemove ...
func (e *JSONResponseEncoder) EncodeMapRemove(response *MapRemoveResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeMapReadState ...
func (e *JSONResponseEncoder) EncodeMapReadState(response *MapReadStateResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeMapReadStream ...
func (e *JSONResponseEncoder) EncodeMapReadStream(response *MapReadStreamResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeMapStats ...
func (e *JSONResponseEncoder) EncodeMapStats(response *MapStatsResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeMapClear ...
func (e *JSONResponseEncoder) EncodeMapClear(response *MapClearResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeSharedPollPublish ...
func (e *JSONResponseEncoder) EncodeSharedPollPublish(response *SharedPollPublishResponse) ([]byte, error) {
return json.Marshal(response)
}

View File

@@ -0,0 +1,115 @@
// Code generated by internal/gen/api/main.go. DO NOT EDIT.
package apiproto
import "encoding/json"
var _ ResultEncoder = (*JSONResultEncoder)(nil)
// JSONResultEncoder ...
type JSONResultEncoder struct{}
// NewJSONResultEncoder ...
func NewJSONResultEncoder() *JSONResultEncoder {
return &JSONResultEncoder{}
}
// EncodePublish ...
func (e *JSONResultEncoder) EncodePublish(res *PublishResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeBroadcast ...
func (e *JSONResultEncoder) EncodeBroadcast(res *BroadcastResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeSubscribe ...
func (e *JSONResultEncoder) EncodeSubscribe(res *SubscribeResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeUnsubscribe ...
func (e *JSONResultEncoder) EncodeUnsubscribe(res *UnsubscribeResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeDisconnect ...
func (e *JSONResultEncoder) EncodeDisconnect(res *DisconnectResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodePresence ...
func (e *JSONResultEncoder) EncodePresence(res *PresenceResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodePresenceStats ...
func (e *JSONResultEncoder) EncodePresenceStats(res *PresenceStatsResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeHistory ...
func (e *JSONResultEncoder) EncodeHistory(res *HistoryResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeHistoryRemove ...
func (e *JSONResultEncoder) EncodeHistoryRemove(res *HistoryRemoveResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeInfo ...
func (e *JSONResultEncoder) EncodeInfo(res *InfoResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeRPC ...
func (e *JSONResultEncoder) EncodeRPC(res *RPCResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeRefresh ...
func (e *JSONResultEncoder) EncodeRefresh(res *RefreshResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeChannels ...
func (e *JSONResultEncoder) EncodeChannels(res *ChannelsResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeMapPublish ...
func (e *JSONResultEncoder) EncodeMapPublish(res *MapPublishResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeMapRemove ...
func (e *JSONResultEncoder) EncodeMapRemove(res *MapRemoveResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeMapReadState ...
func (e *JSONResultEncoder) EncodeMapReadState(res *MapReadStateResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeMapReadStream ...
func (e *JSONResultEncoder) EncodeMapReadStream(res *MapReadStreamResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeMapStats ...
func (e *JSONResultEncoder) EncodeMapStats(res *MapStatsResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeMapClear ...
func (e *JSONResultEncoder) EncodeMapClear(res *MapClearResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeSharedPollPublish ...
func (e *JSONResultEncoder) EncodeSharedPollPublish(res *SharedPollPublishResult) ([]byte, error) {
return json.Marshal(res)
}

View File

@@ -0,0 +1,93 @@
package apiproto
import (
"fmt"
"net/http"
"google.golang.org/grpc/codes"
)
func (x *Error) Error() string {
return fmt.Sprintf("%d: %s", x.Code, x.Message)
}
// Here we define errors that can be exposed in server API replies.
var (
// ErrorInternal means server error, if returned this is a signal
// that something went wrong with Centrifugo itself.
ErrorInternal = &Error{
Code: 100,
Message: "internal server error",
}
// ErrorUnknownChannel means that namespace in channel name does not exist.
ErrorUnknownChannel = &Error{
Code: 102,
Message: "unknown channel",
}
// ErrorNotFound means that method sent in command does not exist.
ErrorNotFound = &Error{
Code: 104,
Message: "not found",
}
// ErrorBadRequest says that Centrifugo can not parse received data
// because it is malformed.
ErrorBadRequest = &Error{
Code: 107,
Message: "bad request",
}
// ErrorNotAvailable means that resource is not enabled.
ErrorNotAvailable = &Error{
Code: 108,
Message: "not available",
}
// ErrorUnrecoverablePosition means that stream does not contain required
// range of publications to fulfill a history query. This can be happen to
// expiration, size limitation or due to wrong epoch.
ErrorUnrecoverablePosition = &Error{
Code: 112,
Message: "unrecoverable position",
}
// ErrorConflict ...
ErrorConflict = &Error{
Code: 113,
Message: "conflict",
}
)
func MapErrorToHTTPCode(err *Error) int {
switch err.Code {
case ErrorInternal.Code:
return http.StatusInternalServerError
case ErrorUnknownChannel.Code, ErrorNotFound.Code:
return http.StatusNotFound
case ErrorBadRequest.Code, ErrorNotAvailable.Code:
return http.StatusBadRequest
case ErrorUnrecoverablePosition.Code:
return http.StatusRequestedRangeNotSatisfiable
case ErrorConflict.Code:
return http.StatusConflict
default:
// Default to Internal Server Error for unmapped errors.
// In general should be avoided - all new API errors must be explicitly described here.
return http.StatusInternalServerError
}
}
func MapErrorToGRPCCode(err *Error) codes.Code {
switch err.Code {
case ErrorInternal.Code:
return codes.Internal
case ErrorUnknownChannel.Code, ErrorNotFound.Code:
return codes.NotFound
case ErrorBadRequest.Code, ErrorNotAvailable.Code:
return codes.InvalidArgument
case ErrorUnrecoverablePosition.Code:
return codes.OutOfRange
case ErrorConflict.Code:
return codes.AlreadyExists
default:
// Default to Internal Error for unmapped errors.
// In general should be avoided - all new API errors must be explicitly described here.
return codes.Internal
}
}

36
internal/apiproto/generate.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
set -e
set -o pipefail
# Optional: Uncomment if you want commands printed as they run
# set -x
# brew install protobuf
# go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
# go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# go install github.com/fatih/gomodifytags@v1.13.0
# go install github.com/FZambia/gomodifytype@latest
# List of required tools
REQUIRED_TOOLS=("protoc" "protoc-gen-go" "protoc-gen-go-grpc" "gomodifytype" "gomodifytags")
# Check that each tool exists in PATH
for tool in "${REQUIRED_TOOLS[@]}"; do
if ! command -v "$tool" &>/dev/null; then
echo "Error: '$tool' not found in PATH. Please install it before running this script."
exit 1
fi
done
echo "Generating Go code from proto..."
protoc -I ./ \
api.proto \
--go_out=. \
--go-grpc_out=.
echo "Modifying types in generated Go code..."
gomodifytype -file api.pb.go -all -w -from "[]byte" -to "Raw"
echo "Generating tags..."
bash generate_tags.sh

View File

@@ -0,0 +1,27 @@
#!/bin/bash
set -e
echo "replacing tags of structs..."
gomodifytags -file api.pb.go -field User -struct ClientInfo -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Client -struct ClientInfo -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Presence -struct PresenceResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field NumClients -struct PresenceStatsResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field NumUsers -struct PresenceStatsResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Offset -struct HistoryResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Epoch -struct HistoryResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Publications -struct HistoryResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Uid -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Name -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Version -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field NumClients -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field NumUsers -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field NumChannels -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field NumSubs -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Channels -struct ChannelsResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Connections -struct ConnectionsResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Statuses -struct GetUserStatusResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Items -struct DeviceListResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Items -struct DeviceTopicListResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Items -struct UserTopicListResult -all -w -remove-options json=omitempty >/dev/null

28
internal/apiproto/raw.go Normal file
View File

@@ -0,0 +1,28 @@
package apiproto
import (
"errors"
)
// Raw type used by Centrifugo as type for fields in structs which value
// we want to left untouched. For example custom application specific JSON
// payload data in published message. This is very similar to json.RawMessage
// type.
type Raw []byte
// MarshalJSON returns *r as the JSON encoding of r.
func (r Raw) MarshalJSON() ([]byte, error) {
if r == nil {
return []byte("null"), nil
}
return r, nil
}
// UnmarshalJSON sets *r to a copy of data.
func (r *Raw) UnmarshalJSON(data []byte) error {
if r == nil {
return errors.New("raw: UnmarshalJSON on nil pointer")
}
*r = append((*r)[0:0], data...)
return nil
}

View File

@@ -0,0 +1,35 @@
package apiproto
import (
"encoding/json"
"testing"
)
type DataRawMessage struct {
Data *json.RawMessage
}
type DataRaw struct {
Data *Raw
}
func TestRaw(t *testing.T) {
data1 := json.RawMessage(`{"key": "value"}`)
stdJsonData1, err := json.Marshal(DataRawMessage{
Data: &data1,
})
if err != nil {
t.Fatalf("%v", err)
}
data2 := Raw(`{"key": "value"}`)
stdJsonData2, err := json.Marshal(DataRaw{
Data: &data2,
})
if err != nil {
t.Fatalf("%v", err)
}
if string(stdJsonData1) != string(stdJsonData2) {
t.Fatalf("no match: %v", err)
}
}

View File

@@ -0,0 +1 @@
Centrifugo server API Protobuf definitions.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
#!/bin/bash
set -e
#go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest
protoc --openapiv2_out=disable_default_errors=true,disable_service_tags=true,openapi_naming_strategy=simple,json_names_for_fields=false,allow_merge=true,merge_file_name=api:. ./api.swagger.proto
search='(:\s+{\s+"type":\s)"string",\s+"format":\s"byte"'
replace='\1"object"'
python3 - << EOF
import re
#defining the replace method
def replace(filePath, text, subs, flags=0):
with open(file_path, "r+") as file:
#read the file contents
file_contents = file.read()
text_pattern = re.compile(text, flags)
file_contents = text_pattern.sub(subs, file_contents)
file.seek(0)
file.truncate()
file.write(file_contents)
file_path="api.swagger.json"
text=r'$search'
subs=r'$replace'
#calling the replace method
replace(file_path, text, subs, flags=re.MULTILINE | re.DOTALL)
EOF
search='(:\s+{\s+"type":\s)"string",\s+"format":\s"uint64"'
replace='\1"integer"'
python3 - << EOF
import re
#defining the replace method
def replace(filePath, text, subs, flags=0):
with open(file_path, "r+") as file:
#read the file contents
file_contents = file.read()
text_pattern = re.compile(text, flags)
file_contents = text_pattern.sub(subs, file_contents)
file.seek(0)
file.truncate()
file.write(file_contents)
file_path="api.swagger.json"
text=r'$search'
subs=r'$replace'
#calling the replace method
replace(file_path, text, subs, flags=re.MULTILINE | re.DOTALL)
EOF
search='(:\s+{\s+"type":\s)"string",\s+"format":\s"int64"'
replace='\1"integer"'
python3 - << EOF
import re
#defining the replace method
def replace(filePath, text, subs, flags=0):
with open(file_path, "r+") as file:
#read the file contents
file_contents = file.read()
text_pattern = re.compile(text, flags)
file_contents = text_pattern.sub(subs, file_contents)
file.seek(0)
file.truncate()
file.write(file_contents)
file_path="api.swagger.json"
text=r'$search'
subs=r'$replace'
#calling the replace method
replace(file_path, text, subs, flags=re.MULTILINE | re.DOTALL)
EOF

View File

@@ -0,0 +1,31 @@
// Copyright 2015 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
syntax = "proto3";
package google.api;
import "google/api/http.proto";
import "google/protobuf/descriptor.proto";
option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
option java_multiple_files = true;
option java_outer_classname = "AnnotationsProto";
option java_package = "com.google.api";
option objc_class_prefix = "GAPI";
extend google.protobuf.MethodOptions {
// See `HttpRule`.
HttpRule http = 72295728;
}

View File

@@ -0,0 +1,375 @@
// Copyright 2015 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
syntax = "proto3";
package google.api;
option cc_enable_arenas = true;
option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
option java_multiple_files = true;
option java_outer_classname = "HttpProto";
option java_package = "com.google.api";
option objc_class_prefix = "GAPI";
// Defines the HTTP configuration for an API service. It contains a list of
// [HttpRule][google.api.HttpRule], each specifying the mapping of an RPC method
// to one or more HTTP REST API methods.
message Http {
// A list of HTTP configuration rules that apply to individual API methods.
//
// **NOTE:** All service configuration rules follow "last one wins" order.
repeated HttpRule rules = 1;
// When set to true, URL path parameters will be fully URI-decoded except in
// cases of single segment matches in reserved expansion, where "%2F" will be
// left encoded.
//
// The default behavior is to not decode RFC 6570 reserved characters in multi
// segment matches.
bool fully_decode_reserved_expansion = 2;
}
// # gRPC Transcoding
//
// gRPC Transcoding is a feature for mapping between a gRPC method and one or
// more HTTP REST endpoints. It allows developers to build a single API service
// that supports both gRPC APIs and REST APIs. Many systems, including [Google
// APIs](https://github.com/googleapis/googleapis),
// [Cloud Endpoints](https://cloud.google.com/endpoints), [gRPC
// Gateway](https://github.com/grpc-ecosystem/grpc-gateway),
// and [Envoy](https://github.com/envoyproxy/envoy) proxy support this feature
// and use it for large scale production services.
//
// `HttpRule` defines the schema of the gRPC/REST mapping. The mapping specifies
// how different portions of the gRPC request message are mapped to the URL
// path, URL query parameters, and HTTP request body. It also controls how the
// gRPC response message is mapped to the HTTP response body. `HttpRule` is
// typically specified as an `google.api.http` annotation on the gRPC method.
//
// Each mapping specifies a URL path template and an HTTP method. The path
// template may refer to one or more fields in the gRPC request message, as long
// as each field is a non-repeated field with a primitive (non-message) type.
// The path template controls how fields of the request message are mapped to
// the URL path.
//
// Example:
//
// service Messaging {
// rpc GetMessage(GetMessageRequest) returns (Message) {
// option (google.api.http) = {
// get: "/v1/{name=messages/*}"
// };
// }
// }
// message GetMessageRequest {
// string name = 1; // Mapped to URL path.
// }
// message Message {
// string text = 1; // The resource content.
// }
//
// This enables an HTTP REST to gRPC mapping as below:
//
// HTTP | gRPC
// -----|-----
// `GET /v1/messages/123456` | `GetMessage(name: "messages/123456")`
//
// Any fields in the request message which are not bound by the path template
// automatically become HTTP query parameters if there is no HTTP request body.
// For example:
//
// service Messaging {
// rpc GetMessage(GetMessageRequest) returns (Message) {
// option (google.api.http) = {
// get:"/v1/messages/{message_id}"
// };
// }
// }
// message GetMessageRequest {
// message SubMessage {
// string subfield = 1;
// }
// string message_id = 1; // Mapped to URL path.
// int64 revision = 2; // Mapped to URL query parameter `revision`.
// SubMessage sub = 3; // Mapped to URL query parameter `sub.subfield`.
// }
//
// This enables a HTTP JSON to RPC mapping as below:
//
// HTTP | gRPC
// -----|-----
// `GET /v1/messages/123456?revision=2&sub.subfield=foo` |
// `GetMessage(message_id: "123456" revision: 2 sub: SubMessage(subfield:
// "foo"))`
//
// Note that fields which are mapped to URL query parameters must have a
// primitive type or a repeated primitive type or a non-repeated message type.
// In the case of a repeated type, the parameter can be repeated in the URL
// as `...?param=A&param=B`. In the case of a message type, each field of the
// message is mapped to a separate parameter, such as
// `...?foo.a=A&foo.b=B&foo.c=C`.
//
// For HTTP methods that allow a request body, the `body` field
// specifies the mapping. Consider a REST update method on the
// message resource collection:
//
// service Messaging {
// rpc UpdateMessage(UpdateMessageRequest) returns (Message) {
// option (google.api.http) = {
// patch: "/v1/messages/{message_id}"
// body: "message"
// };
// }
// }
// message UpdateMessageRequest {
// string message_id = 1; // mapped to the URL
// Message message = 2; // mapped to the body
// }
//
// The following HTTP JSON to RPC mapping is enabled, where the
// representation of the JSON in the request body is determined by
// protos JSON encoding:
//
// HTTP | gRPC
// -----|-----
// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id:
// "123456" message { text: "Hi!" })`
//
// The special name `*` can be used in the body mapping to define that
// every field not bound by the path template should be mapped to the
// request body. This enables the following alternative definition of
// the update method:
//
// service Messaging {
// rpc UpdateMessage(Message) returns (Message) {
// option (google.api.http) = {
// patch: "/v1/messages/{message_id}"
// body: "*"
// };
// }
// }
// message Message {
// string message_id = 1;
// string text = 2;
// }
//
//
// The following HTTP JSON to RPC mapping is enabled:
//
// HTTP | gRPC
// -----|-----
// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id:
// "123456" text: "Hi!")`
//
// Note that when using `*` in the body mapping, it is not possible to
// have HTTP parameters, as all fields not bound by the path end in
// the body. This makes this option more rarely used in practice when
// defining REST APIs. The common usage of `*` is in custom methods
// which don't use the URL at all for transferring data.
//
// It is possible to define multiple HTTP methods for one RPC by using
// the `additional_bindings` option. Example:
//
// service Messaging {
// rpc GetMessage(GetMessageRequest) returns (Message) {
// option (google.api.http) = {
// get: "/v1/messages/{message_id}"
// additional_bindings {
// get: "/v1/users/{user_id}/messages/{message_id}"
// }
// };
// }
// }
// message GetMessageRequest {
// string message_id = 1;
// string user_id = 2;
// }
//
// This enables the following two alternative HTTP JSON to RPC mappings:
//
// HTTP | gRPC
// -----|-----
// `GET /v1/messages/123456` | `GetMessage(message_id: "123456")`
// `GET /v1/users/me/messages/123456` | `GetMessage(user_id: "me" message_id:
// "123456")`
//
// ## Rules for HTTP mapping
//
// 1. Leaf request fields (recursive expansion nested messages in the request
// message) are classified into three categories:
// - Fields referred by the path template. They are passed via the URL path.
// - Fields referred by the [HttpRule.body][google.api.HttpRule.body]. They are passed via the HTTP
// request body.
// - All other fields are passed via the URL query parameters, and the
// parameter name is the field path in the request message. A repeated
// field can be represented as multiple query parameters under the same
// name.
// 2. If [HttpRule.body][google.api.HttpRule.body] is "*", there is no URL query parameter, all fields
// are passed via URL path and HTTP request body.
// 3. If [HttpRule.body][google.api.HttpRule.body] is omitted, there is no HTTP request body, all
// fields are passed via URL path and URL query parameters.
//
// ### Path template syntax
//
// Template = "/" Segments [ Verb ] ;
// Segments = Segment { "/" Segment } ;
// Segment = "*" | "**" | LITERAL | Variable ;
// Variable = "{" FieldPath [ "=" Segments ] "}" ;
// FieldPath = IDENT { "." IDENT } ;
// Verb = ":" LITERAL ;
//
// The syntax `*` matches a single URL path segment. The syntax `**` matches
// zero or more URL path segments, which must be the last part of the URL path
// except the `Verb`.
//
// The syntax `Variable` matches part of the URL path as specified by its
// template. A variable template must not contain other variables. If a variable
// matches a single path segment, its template may be omitted, e.g. `{var}`
// is equivalent to `{var=*}`.
//
// The syntax `LITERAL` matches literal text in the URL path. If the `LITERAL`
// contains any reserved character, such characters should be percent-encoded
// before the matching.
//
// If a variable contains exactly one path segment, such as `"{var}"` or
// `"{var=*}"`, when such a variable is expanded into a URL path on the client
// side, all characters except `[-_.~0-9a-zA-Z]` are percent-encoded. The
// server side does the reverse decoding. Such variables show up in the
// [Discovery
// Document](https://developers.google.com/discovery/v1/reference/apis) as
// `{var}`.
//
// If a variable contains multiple path segments, such as `"{var=foo/*}"`
// or `"{var=**}"`, when such a variable is expanded into a URL path on the
// client side, all characters except `[-_.~/0-9a-zA-Z]` are percent-encoded.
// The server side does the reverse decoding, except "%2F" and "%2f" are left
// unchanged. Such variables show up in the
// [Discovery
// Document](https://developers.google.com/discovery/v1/reference/apis) as
// `{+var}`.
//
// ## Using gRPC API Service Configuration
//
// gRPC API Service Configuration (service config) is a configuration language
// for configuring a gRPC service to become a user-facing product. The
// service config is simply the YAML representation of the `google.api.Service`
// proto message.
//
// As an alternative to annotating your proto file, you can configure gRPC
// transcoding in your service config YAML files. You do this by specifying a
// `HttpRule` that maps the gRPC method to a REST endpoint, achieving the same
// effect as the proto annotation. This can be particularly useful if you
// have a proto that is reused in multiple services. Note that any transcoding
// specified in the service config will override any matching transcoding
// configuration in the proto.
//
// Example:
//
// http:
// rules:
// # Selects a gRPC method and applies HttpRule to it.
// - selector: example.v1.Messaging.GetMessage
// get: /v1/messages/{message_id}/{sub.subfield}
//
// ## Special notes
//
// When gRPC Transcoding is used to map a gRPC to JSON REST endpoints, the
// proto to JSON conversion must follow the [proto3
// specification](https://developers.google.com/protocol-buffers/docs/proto3#json).
//
// While the single segment variable follows the semantics of
// [RFC 6570](https://tools.ietf.org/html/rfc6570) Section 3.2.2 Simple String
// Expansion, the multi segment variable **does not** follow RFC 6570 Section
// 3.2.3 Reserved Expansion. The reason is that the Reserved Expansion
// does not expand special characters like `?` and `#`, which would lead
// to invalid URLs. As the result, gRPC Transcoding uses a custom encoding
// for multi segment variables.
//
// The path variables **must not** refer to any repeated or mapped field,
// because client libraries are not capable of handling such variable expansion.
//
// The path variables **must not** capture the leading "/" character. The reason
// is that the most common use case "{var}" does not capture the leading "/"
// character. For consistency, all path variables must share the same behavior.
//
// Repeated message fields must not be mapped to URL query parameters, because
// no client library can support such complicated mapping.
//
// If an API needs to use a JSON array for request or response body, it can map
// the request or response body to a repeated field. However, some gRPC
// Transcoding implementations may not support this feature.
message HttpRule {
// Selects a method to which this rule applies.
//
// Refer to [selector][google.api.DocumentationRule.selector] for syntax details.
string selector = 1;
// Determines the URL pattern is matched by this rules. This pattern can be
// used with any of the {get|put|post|delete|patch} methods. A custom method
// can be defined using the 'custom' field.
oneof pattern {
// Maps to HTTP GET. Used for listing and getting information about
// resources.
string get = 2;
// Maps to HTTP PUT. Used for replacing a resource.
string put = 3;
// Maps to HTTP POST. Used for creating a resource or performing an action.
string post = 4;
// Maps to HTTP DELETE. Used for deleting a resource.
string delete = 5;
// Maps to HTTP PATCH. Used for updating a resource.
string patch = 6;
// The custom pattern is used for specifying an HTTP method that is not
// included in the `pattern` field, such as HEAD, or "*" to leave the
// HTTP method unspecified for this rule. The wild-card rule is useful
// for services that provide content to Web (HTML) clients.
CustomHttpPattern custom = 8;
}
// The name of the request field whose value is mapped to the HTTP request
// body, or `*` for mapping all request fields not captured by the path
// pattern to the HTTP body, or omitted for not having any HTTP request body.
//
// NOTE: the referred field must be present at the top-level of the request
// message type.
string body = 7;
// Optional. The name of the response field whose value is mapped to the HTTP
// response body. When omitted, the entire response message will be used
// as the HTTP response body.
//
// NOTE: The referred field must be present at the top-level of the response
// message type.
string response_body = 12;
// Additional HTTP bindings for the selector. Nested bindings must
// not contain an `additional_bindings` field themselves (that is,
// the nesting may only be one level deep).
repeated HttpRule additional_bindings = 11;
}
// A custom pattern is used for defining custom HTTP verb.
message CustomHttpPattern {
// The name of this custom HTTP verb.
string kind = 1;
// The path matched by this custom verb.
string path = 2;
}

View File

@@ -0,0 +1,44 @@
syntax = "proto3";
package grpc.gateway.protoc_gen_openapiv2.options;
import "google/protobuf/descriptor.proto";
import "protoc-gen-openapiv2/options/openapiv2.proto";
option go_package = "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options";
extend google.protobuf.FileOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
Swagger openapiv2_swagger = 1042;
}
extend google.protobuf.MethodOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
Operation openapiv2_operation = 1042;
}
extend google.protobuf.MessageOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
Schema openapiv2_schema = 1042;
}
extend google.protobuf.ServiceOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
Tag openapiv2_tag = 1042;
}
extend google.protobuf.FieldOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
JSONSchema openapiv2_field = 1042;
}

View File

@@ -0,0 +1,720 @@
syntax = "proto3";
package grpc.gateway.protoc_gen_openapiv2.options;
import "google/protobuf/struct.proto";
option go_package = "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options";
// Scheme describes the schemes supported by the OpenAPI Swagger
// and Operation objects.
enum Scheme {
UNKNOWN = 0;
HTTP = 1;
HTTPS = 2;
WS = 3;
WSS = 4;
}
// `Swagger` is a representation of OpenAPI v2 specification's Swagger object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#swaggerObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// info: {
// title: "Echo API";
// version: "1.0";
// description: "";
// contact: {
// name: "gRPC-Gateway project";
// url: "https://github.com/grpc-ecosystem/grpc-gateway";
// email: "none@example.com";
// };
// license: {
// name: "BSD 3-Clause License";
// url: "https://github.com/grpc-ecosystem/grpc-gateway/blob/main/LICENSE.txt";
// };
// };
// schemes: HTTPS;
// consumes: "application/json";
// produces: "application/json";
// };
//
message Swagger {
// Specifies the OpenAPI Specification version being used. It can be
// used by the OpenAPI UI and other clients to interpret the API listing. The
// value MUST be "2.0".
string swagger = 1;
// Provides metadata about the API. The metadata can be used by the
// clients if needed.
Info info = 2;
// The host (name or ip) serving the API. This MUST be the host only and does
// not include the scheme nor sub-paths. It MAY include a port. If the host is
// not included, the host serving the documentation is to be used (including
// the port). The host does not support path templating.
string host = 3;
// The base path on which the API is served, which is relative to the host. If
// it is not included, the API is served directly under the host. The value
// MUST start with a leading slash (/). The basePath does not support path
// templating.
// Note that using `base_path` does not change the endpoint paths that are
// generated in the resulting OpenAPI file. If you wish to use `base_path`
// with relatively generated OpenAPI paths, the `base_path` prefix must be
// manually removed from your `google.api.http` paths and your code changed to
// serve the API from the `base_path`.
string base_path = 4;
// The transfer protocol of the API. Values MUST be from the list: "http",
// "https", "ws", "wss". If the schemes is not included, the default scheme to
// be used is the one used to access the OpenAPI definition itself.
repeated Scheme schemes = 5;
// A list of MIME types the APIs can consume. This is global to all APIs but
// can be overridden on specific API calls. Value MUST be as described under
// Mime Types.
repeated string consumes = 6;
// A list of MIME types the APIs can produce. This is global to all APIs but
// can be overridden on specific API calls. Value MUST be as described under
// Mime Types.
repeated string produces = 7;
// field 8 is reserved for 'paths'.
reserved 8;
// field 9 is reserved for 'definitions', which at this time are already
// exposed as and customizable as proto messages.
reserved 9;
// An object to hold responses that can be used across operations. This
// property does not define global responses for all operations.
map<string, Response> responses = 10;
// Security scheme definitions that can be used across the specification.
SecurityDefinitions security_definitions = 11;
// A declaration of which security schemes are applied for the API as a whole.
// The list of values describes alternative security schemes that can be used
// (that is, there is a logical OR between the security requirements).
// Individual operations can override this definition.
repeated SecurityRequirement security = 12;
// A list of tags for API documentation control. Tags can be used for logical
// grouping of operations by resources or any other qualifier.
repeated Tag tags = 13;
// Additional external documentation.
ExternalDocumentation external_docs = 14;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 15;
}
// `Operation` is a representation of OpenAPI v2 specification's Operation object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#operationObject
//
// Example:
//
// service EchoService {
// rpc Echo(SimpleMessage) returns (SimpleMessage) {
// option (google.api.http) = {
// get: "/v1/example/echo/{id}"
// };
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
// summary: "Get a message.";
// operation_id: "getMessage";
// tags: "echo";
// responses: {
// key: "200"
// value: {
// description: "OK";
// }
// }
// };
// }
// }
message Operation {
// A list of tags for API documentation control. Tags can be used for logical
// grouping of operations by resources or any other qualifier.
repeated string tags = 1;
// A short summary of what the operation does. For maximum readability in the
// swagger-ui, this field SHOULD be less than 120 characters.
string summary = 2;
// A verbose explanation of the operation behavior. GFM syntax can be used for
// rich text representation.
string description = 3;
// Additional external documentation for this operation.
ExternalDocumentation external_docs = 4;
// Unique string used to identify the operation. The id MUST be unique among
// all operations described in the API. Tools and libraries MAY use the
// operationId to uniquely identify an operation, therefore, it is recommended
// to follow common programming naming conventions.
string operation_id = 5;
// A list of MIME types the operation can consume. This overrides the consumes
// definition at the OpenAPI Object. An empty value MAY be used to clear the
// global definition. Value MUST be as described under Mime Types.
repeated string consumes = 6;
// A list of MIME types the operation can produce. This overrides the produces
// definition at the OpenAPI Object. An empty value MAY be used to clear the
// global definition. Value MUST be as described under Mime Types.
repeated string produces = 7;
// field 8 is reserved for 'parameters'.
reserved 8;
// The list of possible responses as they are returned from executing this
// operation.
map<string, Response> responses = 9;
// The transfer protocol for the operation. Values MUST be from the list:
// "http", "https", "ws", "wss". The value overrides the OpenAPI Object
// schemes definition.
repeated Scheme schemes = 10;
// Declares this operation to be deprecated. Usage of the declared operation
// should be refrained. Default value is false.
bool deprecated = 11;
// A declaration of which security schemes are applied for this operation. The
// list of values describes alternative security schemes that can be used
// (that is, there is a logical OR between the security requirements). This
// definition overrides any declared top-level security. To remove a top-level
// security declaration, an empty array can be used.
repeated SecurityRequirement security = 12;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 13;
// Custom parameters such as HTTP request headers.
// See: https://swagger.io/docs/specification/2-0/describing-parameters/
// and https://swagger.io/specification/v2/#parameter-object.
Parameters parameters = 14;
}
// `Parameters` is a representation of OpenAPI v2 specification's parameters object.
// Note: This technically breaks compatibility with the OpenAPI 2 definition structure as we only
// allow header parameters to be set here since we do not want users specifying custom non-header
// parameters beyond those inferred from the Protobuf schema.
// See: https://swagger.io/specification/v2/#parameter-object
message Parameters {
// `Headers` is one or more HTTP header parameter.
// See: https://swagger.io/docs/specification/2-0/describing-parameters/#header-parameters
repeated HeaderParameter headers = 1;
}
// `HeaderParameter` a HTTP header parameter.
// See: https://swagger.io/specification/v2/#parameter-object
message HeaderParameter {
// `Type` is a a supported HTTP header type.
// See https://swagger.io/specification/v2/#parameterType.
enum Type {
UNKNOWN = 0;
STRING = 1;
NUMBER = 2;
INTEGER = 3;
BOOLEAN = 4;
}
// `Name` is the header name.
string name = 1;
// `Description` is a short description of the header.
string description = 2;
// `Type` is the type of the object. The value MUST be one of "string", "number", "integer", or "boolean". The "array" type is not supported.
// See: https://swagger.io/specification/v2/#parameterType.
Type type = 3;
// `Format` The extending format for the previously mentioned type.
string format = 4;
// `Required` indicates if the header is optional
bool required = 5;
// field 6 is reserved for 'items', but in OpenAPI-specific way.
reserved 6;
// field 7 is reserved `Collection Format`. Determines the format of the array if type array is used.
reserved 7;
}
// `Header` is a representation of OpenAPI v2 specification's Header object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#headerObject
//
message Header {
// `Description` is a short description of the header.
string description = 1;
// The type of the object. The value MUST be one of "string", "number", "integer", or "boolean". The "array" type is not supported.
string type = 2;
// `Format` The extending format for the previously mentioned type.
string format = 3;
// field 4 is reserved for 'items', but in OpenAPI-specific way.
reserved 4;
// field 5 is reserved `Collection Format` Determines the format of the array if type array is used.
reserved 5;
// `Default` Declares the value of the header that the server will use if none is provided.
// See: https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-6.2.
// Unlike JSON Schema this value MUST conform to the defined type for the header.
string default = 6;
// field 7 is reserved for 'maximum'.
reserved 7;
// field 8 is reserved for 'exclusiveMaximum'.
reserved 8;
// field 9 is reserved for 'minimum'.
reserved 9;
// field 10 is reserved for 'exclusiveMinimum'.
reserved 10;
// field 11 is reserved for 'maxLength'.
reserved 11;
// field 12 is reserved for 'minLength'.
reserved 12;
// 'Pattern' See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.2.3.
string pattern = 13;
// field 14 is reserved for 'maxItems'.
reserved 14;
// field 15 is reserved for 'minItems'.
reserved 15;
// field 16 is reserved for 'uniqueItems'.
reserved 16;
// field 17 is reserved for 'enum'.
reserved 17;
// field 18 is reserved for 'multipleOf'.
reserved 18;
}
// `Response` is a representation of OpenAPI v2 specification's Response object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#responseObject
//
message Response {
// `Description` is a short description of the response.
// GFM syntax can be used for rich text representation.
string description = 1;
// `Schema` optionally defines the structure of the response.
// If `Schema` is not provided, it means there is no content to the response.
Schema schema = 2;
// `Headers` A list of headers that are sent with the response.
// `Header` name is expected to be a string in the canonical format of the MIME header key
// See: https://golang.org/pkg/net/textproto/#CanonicalMIMEHeaderKey
map<string, Header> headers = 3;
// `Examples` gives per-mimetype response examples.
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#example-object
map<string, string> examples = 4;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 5;
}
// `Info` is a representation of OpenAPI v2 specification's Info object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#infoObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// info: {
// title: "Echo API";
// version: "1.0";
// description: "";
// contact: {
// name: "gRPC-Gateway project";
// url: "https://github.com/grpc-ecosystem/grpc-gateway";
// email: "none@example.com";
// };
// license: {
// name: "BSD 3-Clause License";
// url: "https://github.com/grpc-ecosystem/grpc-gateway/blob/main/LICENSE.txt";
// };
// };
// ...
// };
//
message Info {
// The title of the application.
string title = 1;
// A short description of the application. GFM syntax can be used for rich
// text representation.
string description = 2;
// The Terms of Service for the API.
string terms_of_service = 3;
// The contact information for the exposed API.
Contact contact = 4;
// The license information for the exposed API.
License license = 5;
// Provides the version of the application API (not to be confused
// with the specification version).
string version = 6;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 7;
}
// `Contact` is a representation of OpenAPI v2 specification's Contact object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#contactObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// info: {
// ...
// contact: {
// name: "gRPC-Gateway project";
// url: "https://github.com/grpc-ecosystem/grpc-gateway";
// email: "none@example.com";
// };
// ...
// };
// ...
// };
//
message Contact {
// The identifying name of the contact person/organization.
string name = 1;
// The URL pointing to the contact information. MUST be in the format of a
// URL.
string url = 2;
// The email address of the contact person/organization. MUST be in the format
// of an email address.
string email = 3;
}
// `License` is a representation of OpenAPI v2 specification's License object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#licenseObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// info: {
// ...
// license: {
// name: "BSD 3-Clause License";
// url: "https://github.com/grpc-ecosystem/grpc-gateway/blob/main/LICENSE.txt";
// };
// ...
// };
// ...
// };
//
message License {
// The license name used for the API.
string name = 1;
// A URL to the license used for the API. MUST be in the format of a URL.
string url = 2;
}
// `ExternalDocumentation` is a representation of OpenAPI v2 specification's
// ExternalDocumentation object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#externalDocumentationObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// ...
// external_docs: {
// description: "More about gRPC-Gateway";
// url: "https://github.com/grpc-ecosystem/grpc-gateway";
// }
// ...
// };
//
message ExternalDocumentation {
// A short description of the target documentation. GFM syntax can be used for
// rich text representation.
string description = 1;
// The URL for the target documentation. Value MUST be in the format
// of a URL.
string url = 2;
}
// `Schema` is a representation of OpenAPI v2 specification's Schema object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#schemaObject
//
message Schema {
JSONSchema json_schema = 1;
// Adds support for polymorphism. The discriminator is the schema property
// name that is used to differentiate between other schema that inherit this
// schema. The property name used MUST be defined at this schema and it MUST
// be in the required property list. When used, the value MUST be the name of
// this schema or any schema that inherits it.
string discriminator = 2;
// Relevant only for Schema "properties" definitions. Declares the property as
// "read only". This means that it MAY be sent as part of a response but MUST
// NOT be sent as part of the request. Properties marked as readOnly being
// true SHOULD NOT be in the required list of the defined schema. Default
// value is false.
bool read_only = 3;
// field 4 is reserved for 'xml'.
reserved 4;
// Additional external documentation for this schema.
ExternalDocumentation external_docs = 5;
// A free-form property to include an example of an instance for this schema in JSON.
// This is copied verbatim to the output.
string example = 6;
}
// `JSONSchema` represents properties from JSON Schema taken, and as used, in
// the OpenAPI v2 spec.
//
// This includes changes made by OpenAPI v2.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#schemaObject
//
// See also: https://cswr.github.io/JsonSchema/spec/basic_types/,
// https://github.com/json-schema-org/json-schema-spec/blob/master/schema.json
//
// Example:
//
// message SimpleMessage {
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
// json_schema: {
// title: "SimpleMessage"
// description: "A simple message."
// required: ["id"]
// }
// };
//
// // Id represents the message identifier.
// string id = 1; [
// (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
// description: "The unique identifier of the simple message."
// }];
// }
//
message JSONSchema {
// field 1 is reserved for '$id', omitted from OpenAPI v2.
reserved 1;
// field 2 is reserved for '$schema', omitted from OpenAPI v2.
reserved 2;
// Ref is used to define an external reference to include in the message.
// This could be a fully qualified proto message reference, and that type must
// be imported into the protofile. If no message is identified, the Ref will
// be used verbatim in the output.
// For example:
// `ref: ".google.protobuf.Timestamp"`.
string ref = 3;
// field 4 is reserved for '$comment', omitted from OpenAPI v2.
reserved 4;
// The title of the schema.
string title = 5;
// A short description of the schema.
string description = 6;
string default = 7;
bool read_only = 8;
// A free-form property to include a JSON example of this field. This is copied
// verbatim to the output swagger.json. Quotes must be escaped.
// This property is the same for 2.0 and 3.0.0 https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/3.0.0.md#schemaObject https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#schemaObject
string example = 9;
double multiple_of = 10;
// Maximum represents an inclusive upper limit for a numeric instance. The
// value of MUST be a number,
double maximum = 11;
bool exclusive_maximum = 12;
// minimum represents an inclusive lower limit for a numeric instance. The
// value of MUST be a number,
double minimum = 13;
bool exclusive_minimum = 14;
uint64 max_length = 15;
uint64 min_length = 16;
string pattern = 17;
// field 18 is reserved for 'additionalItems', omitted from OpenAPI v2.
reserved 18;
// field 19 is reserved for 'items', but in OpenAPI-specific way.
// TODO(ivucica): add 'items'?
reserved 19;
uint64 max_items = 20;
uint64 min_items = 21;
bool unique_items = 22;
// field 23 is reserved for 'contains', omitted from OpenAPI v2.
reserved 23;
uint64 max_properties = 24;
uint64 min_properties = 25;
repeated string required = 26;
// field 27 is reserved for 'additionalProperties', but in OpenAPI-specific
// way. TODO(ivucica): add 'additionalProperties'?
reserved 27;
// field 28 is reserved for 'definitions', omitted from OpenAPI v2.
reserved 28;
// field 29 is reserved for 'properties', but in OpenAPI-specific way.
// TODO(ivucica): add 'additionalProperties'?
reserved 29;
// following fields are reserved, as the properties have been omitted from
// OpenAPI v2:
// patternProperties, dependencies, propertyNames, const
reserved 30 to 33;
// Items in 'array' must be unique.
repeated string array = 34;
enum JSONSchemaSimpleTypes {
UNKNOWN = 0;
ARRAY = 1;
BOOLEAN = 2;
INTEGER = 3;
NULL = 4;
NUMBER = 5;
OBJECT = 6;
STRING = 7;
}
repeated JSONSchemaSimpleTypes type = 35;
// `Format`
string format = 36;
// following fields are reserved, as the properties have been omitted from
// OpenAPI v2: contentMediaType, contentEncoding, if, then, else
reserved 37 to 41;
// field 42 is reserved for 'allOf', but in OpenAPI-specific way.
// TODO(ivucica): add 'allOf'?
reserved 42;
// following fields are reserved, as the properties have been omitted from
// OpenAPI v2:
// anyOf, oneOf, not
reserved 43 to 45;
// Items in `enum` must be unique https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.5.1
repeated string enum = 46;
// Additional field level properties used when generating the OpenAPI v2 file.
FieldConfiguration field_configuration = 1001;
// 'FieldConfiguration' provides additional field level properties used when generating the OpenAPI v2 file.
// These properties are not defined by OpenAPIv2, but they are used to control the generation.
message FieldConfiguration {
// Alternative parameter name when used as path parameter. If set, this will
// be used as the complete parameter name when this field is used as a path
// parameter. Use this to avoid having auto generated path parameter names
// for overlapping paths.
string path_param_name = 47;
}
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 48;
}
// `Tag` is a representation of OpenAPI v2 specification's Tag object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#tagObject
//
message Tag {
// The name of the tag. Use it to allow override of the name of a
// global Tag object, then use that name to reference the tag throughout the
// OpenAPI file.
string name = 1;
// A short description for the tag. GFM syntax can be used for rich text
// representation.
string description = 2;
// Additional external documentation for this tag.
ExternalDocumentation external_docs = 3;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 4;
}
// `SecurityDefinitions` is a representation of OpenAPI v2 specification's
// Security Definitions object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#securityDefinitionsObject
//
// A declaration of the security schemes available to be used in the
// specification. This does not enforce the security schemes on the operations
// and only serves to provide the relevant details for each scheme.
message SecurityDefinitions {
// A single security scheme definition, mapping a "name" to the scheme it
// defines.
map<string, SecurityScheme> security = 1;
}
// `SecurityScheme` is a representation of OpenAPI v2 specification's
// Security Scheme object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#securitySchemeObject
//
// Allows the definition of a security scheme that can be used by the
// operations. Supported schemes are basic authentication, an API key (either as
// a header or as a query parameter) and OAuth2's common flows (implicit,
// password, application and access code).
message SecurityScheme {
// The type of the security scheme. Valid values are "basic",
// "apiKey" or "oauth2".
enum Type {
TYPE_INVALID = 0;
TYPE_BASIC = 1;
TYPE_API_KEY = 2;
TYPE_OAUTH2 = 3;
}
// The location of the API key. Valid values are "query" or "header".
enum In {
IN_INVALID = 0;
IN_QUERY = 1;
IN_HEADER = 2;
}
// The flow used by the OAuth2 security scheme. Valid values are
// "implicit", "password", "application" or "accessCode".
enum Flow {
FLOW_INVALID = 0;
FLOW_IMPLICIT = 1;
FLOW_PASSWORD = 2;
FLOW_APPLICATION = 3;
FLOW_ACCESS_CODE = 4;
}
// The type of the security scheme. Valid values are "basic",
// "apiKey" or "oauth2".
Type type = 1;
// A short description for security scheme.
string description = 2;
// The name of the header or query parameter to be used.
// Valid for apiKey.
string name = 3;
// The location of the API key. Valid values are "query" or
// "header".
// Valid for apiKey.
In in = 4;
// The flow used by the OAuth2 security scheme. Valid values are
// "implicit", "password", "application" or "accessCode".
// Valid for oauth2.
Flow flow = 5;
// The authorization URL to be used for this flow. This SHOULD be in
// the form of a URL.
// Valid for oauth2/implicit and oauth2/accessCode.
string authorization_url = 6;
// The token URL to be used for this flow. This SHOULD be in the
// form of a URL.
// Valid for oauth2/password, oauth2/application and oauth2/accessCode.
string token_url = 7;
// The available scopes for the OAuth2 security scheme.
// Valid for oauth2.
Scopes scopes = 8;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 9;
}
// `SecurityRequirement` is a representation of OpenAPI v2 specification's
// Security Requirement object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#securityRequirementObject
//
// Lists the required security schemes to execute this operation. The object can
// have multiple security schemes declared in it which are all required (that
// is, there is a logical AND between the schemes).
//
// The name used for each property MUST correspond to a security scheme
// declared in the Security Definitions.
message SecurityRequirement {
// If the security scheme is of type "oauth2", then the value is a list of
// scope names required for the execution. For other security scheme types,
// the array MUST be empty.
message SecurityRequirementValue {
repeated string scope = 1;
}
// Each name must correspond to a security scheme which is declared in
// the Security Definitions. If the security scheme is of type "oauth2",
// then the value is a list of scope names required for the execution.
// For other security scheme types, the array MUST be empty.
map<string, SecurityRequirementValue> security_requirement = 1;
}
// `Scopes` is a representation of OpenAPI v2 specification's Scopes object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#scopesObject
//
// Lists the available scopes for an OAuth2 security scheme.
message Scopes {
// Maps between a name of a scope to a short description of it (as the value
// of the property).
map<string, string> scope = 1;
}

21
internal/app/cmd.go Normal file
View File

@@ -0,0 +1,21 @@
package app
import (
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/spf13/cobra"
)
func Centrifugo() *cobra.Command {
var configFile string
cmd := &cobra.Command{
Use: "",
Short: "Centrifugo",
Long: "Centrifugo – scalable real-time messaging server in language-agnostic way",
Run: func(cmd *cobra.Command, args []string) {
Run(cmd, configFile)
},
}
cmd.Flags().StringVarP(&configFile, "config", "c", "config.json", "path to config file")
config.DefineFlags(cmd)
return cmd
}

326
internal/app/engine.go Normal file
View File

@@ -0,0 +1,326 @@
package app
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/confighelpers"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/controllers"
"github.com/centrifugal/centrifugo/v6/internal/natsbroker"
"github.com/centrifugal/centrifugo/v6/internal/pgmapbroker"
"github.com/centrifugal/centrifugo/v6/internal/pgstreambroker"
"github.com/centrifugal/centrifugo/v6/internal/redisnatsbroker"
"github.com/centrifugal/centrifuge"
"github.com/rs/zerolog/log"
)
func configureEngines(node *centrifuge.Node, cfgContainer *config.Container) error {
cfg := cfgContainer.Config()
var broker centrifuge.Broker
var presenceManager centrifuge.PresenceManager
if !cfg.Broker.Enabled || !cfg.PresenceManager.Enabled {
var err error
var engineMode string
switch cfg.Engine.Type {
case "memory":
broker, presenceManager, err = createMemoryEngine(node)
case "redis":
broker, presenceManager, engineMode, err = createRedisEngine(node, cfgContainer)
default:
return fmt.Errorf("unknown engine type: %s", cfg.Engine.Type)
}
event := log.Info().Str("engine_type", cfg.Engine.Type)
if engineMode != "" {
event.Str("engine_mode", engineMode)
}
event.Msg("initializing engine")
if err != nil {
return fmt.Errorf("error creating engine: %v", err)
}
} else {
log.Info().Msgf("both broker and presence manager enabled, skip engine initialization")
}
if cfg.Broker.Enabled {
var err error
var brokerMode string
switch cfg.Broker.Type {
case "memory":
broker, err = createMemoryBroker(node)
case "redis":
broker, brokerMode, err = createRedisBroker(node, cfgContainer)
case "nats":
broker, err = NatsBroker(node, cfg)
brokerMode = "nats"
case "postgres":
broker, err = createPostgresStreamBroker(node, cfg.Broker.Postgres)
brokerMode = "postgres"
case "redisnats":
if !cfg.EnableUnreleasedFeatures {
return fmt.Errorf("redisnats broker requires enable_unreleased_features on")
}
log.Warn().Msg("redisnats broker is not released, it may be changed or removed at any point")
redisBroker, redisBrokerMode, err := createRedisBroker(node, cfgContainer)
if err != nil {
return fmt.Errorf("error creating redis broker: %v", err)
}
brokerMode = redisBrokerMode + "+nats"
natsBroker, err := NatsBroker(node, cfg)
if err != nil {
return fmt.Errorf("error creating nats broker: %v", err)
}
broker, err = redisnatsbroker.New(natsBroker, redisBroker)
if err != nil {
return fmt.Errorf("error creating redisnats broker: %v", err)
}
default:
return fmt.Errorf("unknown broker type: %s", cfg.Broker.Type)
}
if err != nil {
return fmt.Errorf("error creating broker: %v", err)
}
event := log.Info().Str("broker_type", cfg.Broker.Type)
if brokerMode != "" {
event.Str("broker_mode", brokerMode)
}
event.Msg("broker is enabled, using it instead of broker from engine")
} else {
log.Info().Msgf("explicit broker not provided, using the one from engine")
}
if cfg.PresenceManager.Enabled {
var err error
var presenceManagerMode string
switch cfg.PresenceManager.Type {
case "memory":
presenceManager, err = createMemoryPresenceManager(node)
case "redis":
presenceManager, presenceManagerMode, err = createRedisPresenceManager(node, cfgContainer)
default:
return fmt.Errorf("unknown presence manager type: %s", cfg.PresenceManager.Type)
}
if err != nil {
return fmt.Errorf("error creating presence manager: %v", err)
}
event := log.Info().Str("presence_manager_type", cfg.PresenceManager.Type)
if presenceManagerMode != "" {
event.Str("presence_manager_mode", presenceManagerMode)
}
event.Msg("presence manager is enabled, using it instead of presence manager from engine")
} else {
log.Info().Msgf("explicit presence manager not provided, using the one from engine")
}
if cfg.Controller.Enabled {
controller, err := controllers.New(node, cfg.Controller)
if err != nil {
return fmt.Errorf("error creating controller: %v", err)
}
if controller != nil {
node.SetController(controller)
}
}
node.SetBroker(broker)
node.SetPresenceManager(presenceManager)
return nil
}
func createMemoryBroker(n *centrifuge.Node) (centrifuge.Broker, error) {
brokerConf, err := memoryBrokerConfig()
if err != nil {
return nil, err
}
broker, err := centrifuge.NewMemoryBroker(n, *brokerConf)
if err != nil {
return nil, err
}
return broker, nil
}
func createPostgresStreamBroker(node *centrifuge.Node, pgCfg configtypes.PostgresStreamBroker) (centrifuge.Broker, error) {
pgBrokerCfg := pgstreambroker.PostgresStreamBrokerConfig{
DSN: pgCfg.DSN,
TLS: pgCfg.TLS,
PoolSize: pgCfg.PoolSize,
NumShards: pgCfg.NumShards,
CleanupInterval: pgCfg.CleanupInterval.ToDuration(),
IdempotentResultTTL: pgCfg.IdempotentResultTTL.ToDuration(),
BinaryData: pgCfg.BinaryData,
StreamRetention: pgCfg.StreamRetention.ToDuration(),
UseNotify: pgCfg.UseNotify,
NotifyDSN: pgCfg.NotifyDSN,
TablePrefix: pgCfg.TablePrefix,
PartitionLookaheadDays: pgCfg.PartitionLookaheadDays,
PartitionRetentionDays: pgCfg.PartitionRetentionDays,
Outbox: pgstreambroker.OutboxConfig{
PollInterval: pgCfg.Outbox.PollInterval.ToDuration(),
BatchSize: pgCfg.Outbox.BatchSize,
},
}
broker, err := pgstreambroker.NewPostgresStreamBroker(node, pgBrokerCfg)
if err != nil {
return nil, fmt.Errorf("error creating Postgres stream broker: %w", err)
}
if !pgCfg.SkipSchemaInit {
if schemaErr := broker.EnsureSchema(context.Background()); schemaErr != nil {
return nil, fmt.Errorf("error initializing Postgres stream broker schema: %w", schemaErr)
}
}
return broker, nil
}
func createMemoryPresenceManager(n *centrifuge.Node) (centrifuge.PresenceManager, error) {
presenceManagerConf, err := memoryPresenceManagerConfig()
if err != nil {
return nil, err
}
return centrifuge.NewMemoryPresenceManager(n, *presenceManagerConf)
}
func createMemoryEngine(n *centrifuge.Node) (centrifuge.Broker, centrifuge.PresenceManager, error) {
broker, err := createMemoryBroker(n)
if err != nil {
return nil, nil, err
}
presenceManager, err := createMemoryPresenceManager(n)
if err != nil {
return nil, nil, err
}
return broker, presenceManager, nil
}
func memoryBrokerConfig() (*centrifuge.MemoryBrokerConfig, error) {
return &centrifuge.MemoryBrokerConfig{}, nil
}
func memoryPresenceManagerConfig() (*centrifuge.MemoryPresenceManagerConfig, error) {
return &centrifuge.MemoryPresenceManagerConfig{}, nil
}
func NatsBroker(node *centrifuge.Node, cfg config.Config) (*natsbroker.NatsBroker, error) {
return natsbroker.New(node, cfg.Broker.Nats)
}
func createRedisEngine(n *centrifuge.Node, cfgContainer *config.Container) (*centrifuge.RedisBroker, centrifuge.PresenceManager, string, error) {
cfg := cfgContainer.Config()
redisShards, mode, err := confighelpers.CentrifugeRedisShards(n, cfg.Engine.Redis.Redis)
if err != nil {
return nil, nil, mode, fmt.Errorf("error creating Redis shards: %w", err)
}
var broker *centrifuge.RedisBroker
if !cfg.Broker.Enabled {
broker, err = confighelpers.CentrifugeRedisBroker(
n, cfg.Engine.Redis.Prefix, redisShards, cfg.Engine.Redis.RedisBrokerCommon, false)
if err != nil {
return nil, nil, mode, fmt.Errorf("error creating Redis broker: %w", err)
}
}
var presenceManager centrifuge.PresenceManager
if !cfg.PresenceManager.Enabled {
presenceManager, err = confighelpers.CentrifugeRedisPresenceManager(
n, cfg.Engine.Redis.Prefix, redisShards, cfg.Engine.Redis.RedisPresenceManagerCommon)
if err != nil {
return nil, nil, mode, fmt.Errorf("error creating Redis presence manager: %w", err)
}
}
return broker, presenceManager, mode, nil
}
func createRedisBroker(n *centrifuge.Node, cfgContainer *config.Container) (*centrifuge.RedisBroker, string, error) {
cfg := cfgContainer.Config()
redisShards, mode, err := confighelpers.CentrifugeRedisShards(n, cfg.Broker.Redis.Redis)
if err != nil {
return nil, "", fmt.Errorf("error creating Redis shards: %w", err)
}
broker, err := confighelpers.CentrifugeRedisBroker(
n, cfg.Broker.Redis.Prefix, redisShards, cfg.Broker.Redis.RedisBrokerCommon, cfg.Broker.Type == "redisnats")
if err != nil {
return nil, mode, fmt.Errorf("error creating Redis broker: %w", err)
}
return broker, mode, nil
}
func createRedisPresenceManager(n *centrifuge.Node, cfgContainer *config.Container) (centrifuge.PresenceManager, string, error) {
cfg := cfgContainer.Config()
redisShards, mode, err := confighelpers.CentrifugeRedisShards(n, cfg.PresenceManager.Redis.Redis)
if err != nil {
return nil, "", fmt.Errorf("error creating Redis shards: %w", err)
}
presenceManager, err := confighelpers.CentrifugeRedisPresenceManager(
n, cfg.PresenceManager.Redis.Prefix, redisShards, cfg.PresenceManager.Redis.RedisPresenceManagerCommon)
if err != nil {
return nil, mode, fmt.Errorf("error creating Redis presence manager: %w", err)
}
return presenceManager, mode, nil
}
func configureMapBroker(node *centrifuge.Node, cfgContainer *config.Container) error {
cfg := cfgContainer.Config()
var mapBroker centrifuge.MapBroker
var mapBrokerMode string
var err error
switch cfg.MapBroker.Type {
case "memory":
mapBroker, err = centrifuge.NewMemoryMapBroker(node, centrifuge.MemoryMapBrokerConfig{})
case "redis":
var redisShards []*centrifuge.RedisShard
redisShards, mapBrokerMode, err = confighelpers.CentrifugeRedisShards(node, cfg.MapBroker.Redis.Redis)
if err != nil {
return fmt.Errorf("error creating Redis shards for map broker: %w", err)
}
mapBroker, err = confighelpers.CentrifugeRedisMapBroker(
node, cfg.MapBroker.Redis.Prefix, redisShards, cfg.MapBroker.Redis.RedisMapBrokerCommon)
case "postgres":
pgCfg := cfg.MapBroker.Postgres
pgBrokerCfg := pgmapbroker.PostgresMapBrokerConfig{
DSN: pgCfg.DSN,
TLS: pgCfg.TLS,
PoolSize: pgCfg.PoolSize,
NumShards: pgCfg.NumShards,
TTLCheckInterval: pgCfg.TTLCheckInterval.ToDuration(),
CleanupInterval: pgCfg.CleanupInterval.ToDuration(),
IdempotentResultTTL: pgCfg.IdempotentResultTTL.ToDuration(),
BinaryData: pgCfg.BinaryData,
StreamRetention: pgCfg.StreamRetention.ToDuration(),
UseNotify: pgCfg.UseNotify,
NotifyDSN: pgCfg.NotifyDSN,
TablePrefix: pgCfg.TablePrefix,
PartitionLookaheadDays: pgCfg.PartitionLookaheadDays,
PartitionRetentionDays: pgCfg.PartitionRetentionDays,
Outbox: pgmapbroker.OutboxConfig{
PollInterval: pgCfg.Outbox.PollInterval.ToDuration(),
BatchSize: pgCfg.Outbox.BatchSize,
},
}
mapBroker, err = pgmapbroker.NewPostgresMapBroker(node, pgBrokerCfg)
if err != nil {
return fmt.Errorf("error creating Postgres map broker: %w", err)
}
if !pgCfg.SkipSchemaInit {
pgBroker := mapBroker.(*pgmapbroker.PostgresMapBroker)
if schemaErr := pgBroker.EnsureSchema(context.Background()); schemaErr != nil {
return fmt.Errorf("error initializing Postgres map broker schema: %w", schemaErr)
}
}
mapBrokerMode = "postgres"
default:
return fmt.Errorf("unknown map broker type: %s", cfg.MapBroker.Type)
}
if err != nil {
return fmt.Errorf("error creating map broker: %v", err)
}
event := log.Info().Str("map_broker_type", cfg.MapBroker.Type)
if mapBrokerMode != "" {
event.Str("map_broker_mode", mapBrokerMode)
}
event.Msg("initializing map broker")
node.SetMapBroker(mapBroker)
return nil
}

23
internal/app/graphite.go Normal file
View File

@@ -0,0 +1,23 @@
package app
import (
"net"
"strconv"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/metrics/graphite"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
)
func graphiteExporter(cfg config.Config, nodeCfg centrifuge.Config) *graphite.Exporter {
return graphite.New(graphite.Config{
Address: net.JoinHostPort(cfg.Graphite.Host, strconv.Itoa(cfg.Graphite.Port)),
Gatherer: prometheus.DefaultGatherer,
Prefix: strings.TrimSuffix(cfg.Graphite.Prefix, ".") + "." + graphite.PreparePathComponent(nodeCfg.Name),
Interval: cfg.Graphite.Interval.ToDuration(),
Tags: cfg.Graphite.Tags,
})
}

120
internal/app/grpc.go Normal file
View File

@@ -0,0 +1,120 @@
package app
import (
"crypto/tls"
"fmt"
"net"
"strconv"
"time"
"github.com/centrifugal/centrifugo/v6/internal/api"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifugo/v6/internal/unigrpc"
"github.com/centrifugal/centrifuge"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/reflection"
)
func runGRPCAPIServer(cfg config.Config, node *centrifuge.Node, useAPIOpentelemetry bool, grpcAPIExecutor *api.Executor) (*grpc.Server, error) {
grpcAPIAddr := net.JoinHostPort(cfg.GrpcAPI.Address, strconv.Itoa(cfg.GrpcAPI.Port))
grpcAPIConn, err := net.Listen("tcp", grpcAPIAddr)
if err != nil {
return nil, fmt.Errorf("cannot listen to address %s", grpcAPIAddr)
}
var grpcOpts []grpc.ServerOption
// NOTE: not a bug – GRPC API without a key is intentional and documented behaviour in
// Centrifugo v6, see https://centrifugal.dev/docs/server/server_api. Unlike the HTTP API,
// GRPC API is disabled by default and runs on its own port, separate from the main HTTP
// server, so it's expected to be exposed to an internal network only. It may also be
// protected without a key – by mutual TLS via grpc_api.tls, or JWKS-based auth in PRO.
// Do not make an empty key fail-closed here: it silently breaks existing setups. In v7 we
// may require at least one auth method unless an explicit insecure flag is set.
if cfg.GrpcAPI.Key != "" {
grpcOpts = append(grpcOpts, api.GRPCKeyAuth(cfg.GrpcAPI.Key))
}
if cfg.GrpcAPI.MaxReceiveMessageSize > 0 {
grpcOpts = append(grpcOpts, grpc.MaxRecvMsgSize(cfg.GrpcAPI.MaxReceiveMessageSize))
}
var grpcAPITLSConfig *tls.Config
if cfg.GrpcAPI.TLS.Enabled {
grpcAPITLSConfig, err = cfg.GrpcAPI.TLS.ToGoTLSConfig("grpc_api")
if err != nil {
return nil, fmt.Errorf("error getting TLS config for GRPC API: %v", err)
}
}
if grpcAPITLSConfig != nil {
grpcOpts = append(grpcOpts, grpc.Creds(credentials.NewTLS(grpcAPITLSConfig)))
}
if cfg.OpenTelemetry.Enabled && cfg.OpenTelemetry.API {
grpcOpts = append(grpcOpts, grpc.StatsHandler(otelgrpc.NewServerHandler()))
}
grpcErrorMode, err := tools.OptionalStringChoice(cfg.GrpcAPI.ErrorMode, []string{config.TransportErrorMode})
if err != nil {
return nil, fmt.Errorf("can't extract grpc error mode: %v", err)
}
grpcAPIServer := grpc.NewServer(grpcOpts...)
_ = api.RegisterGRPCServerAPI(node, grpcAPIExecutor, grpcAPIServer, api.GRPCAPIServiceConfig{
UseOpenTelemetry: useAPIOpentelemetry,
UseTransportErrorMode: grpcErrorMode == config.TransportErrorMode,
})
if cfg.GrpcAPI.Reflection {
reflection.Register(grpcAPIServer)
}
log.Info().Msgf("serving GRPC API service on %s", grpcAPIAddr)
go func() {
if err := grpcAPIServer.Serve(grpcAPIConn); err != nil {
log.Fatal().Err(err).Msg("serve GRPC API")
}
}()
return grpcAPIServer, nil
}
func runGRPCUniServer(cfg config.Config, node *centrifuge.Node) (*grpc.Server, error) {
grpcUniAddr := net.JoinHostPort(cfg.UniGRPC.Address, strconv.Itoa(cfg.UniGRPC.Port))
grpcUniConn, err := net.Listen("tcp", grpcUniAddr)
if err != nil {
return nil, fmt.Errorf("cannot listen to address %s", grpcUniAddr)
}
var grpcOpts []grpc.ServerOption
grpcOpts = append(grpcOpts, grpc.ForceServerCodec(&unigrpc.RawCodec{}))
if cfg.UniGRPC.MaxReceiveMessageSize > 0 {
grpcOpts = append(grpcOpts, grpc.MaxRecvMsgSize(cfg.UniGRPC.MaxReceiveMessageSize))
}
var uniGrpcTLSConfig *tls.Config
if cfg.UniGRPC.TLS.Enabled {
uniGrpcTLSConfig, err = cfg.UniGRPC.TLS.ToGoTLSConfig("uni_grpc")
if err != nil {
return nil, fmt.Errorf("error getting TLS config for uni GRPC: %v", err)
}
}
if uniGrpcTLSConfig != nil {
grpcOpts = append(grpcOpts, grpc.Creds(credentials.NewTLS(uniGrpcTLSConfig)))
}
keepAliveEnforcementPolicy := keepalive.EnforcementPolicy{
MinTime: 5 * time.Second,
}
keepAliveServerParams := keepalive.ServerParameters{
Time: 25 * time.Second,
Timeout: 5 * time.Second,
}
grpcOpts = append(grpcOpts, grpc.KeepaliveEnforcementPolicy(keepAliveEnforcementPolicy))
grpcOpts = append(grpcOpts, grpc.KeepaliveParams(keepAliveServerParams))
grpcUniServer := grpc.NewServer(grpcOpts...)
_ = unigrpc.RegisterService(grpcUniServer, unigrpc.NewService(node, cfg.UniGRPC))
log.Info().Msgf("serving unidirectional GRPC on %s", grpcUniAddr)
go func() {
if err := grpcUniServer.Serve(grpcUniConn); err != nil {
log.Fatal().Err(err).Msg("serve uni GRPC")
}
}()
return grpcUniServer, nil
}

47
internal/app/log.go Normal file
View File

@@ -0,0 +1,47 @@
package app
import (
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func logStartWarnings(cfg config.Config, cfgMeta config.Meta) {
if cfg.Client.Insecure {
log.Warn().Msg("INSECURE client mode enabled, make sure you understand risks")
}
if cfg.HttpAPI.Insecure {
log.Warn().Msg("INSECURE HTTP API mode enabled, make sure you understand risks")
}
if cfg.Admin.Insecure {
log.Warn().Msg("INSECURE admin mode enabled, make sure you understand risks")
}
if cfg.Client.InsecureSkipTokenSignatureVerify {
log.Warn().Msg("INSECURE skip token signature verify enabled, make sure you understand risks")
}
if cfg.Debug.Enabled {
log.Warn().Msg("DEBUG mode enabled, see on " + cfg.Debug.HandlerPrefix)
}
for _, key := range cfgMeta.UnknownKeys {
log.Warn().Str("key", key).Msg("unknown key in configuration file")
}
for _, key := range cfgMeta.UnknownEnvs {
log.Warn().Str("var", key).Msg("unknown var in environment")
}
for _, msg := range cfgMeta.DeprecationWarnings {
log.Warn().Msg(msg)
}
}
type httpErrorLogWriter struct {
zerolog.Logger
}
func (w *httpErrorLogWriter) Write(data []byte) (int, error) {
w.Logger.Warn().Msg(strings.TrimSpace(string(data)))
return len(data), nil
}

635
internal/app/mux.go Normal file
View File

@@ -0,0 +1,635 @@
package app
import (
"crypto/tls"
"errors"
stdlog "log"
"net"
"net/http"
"net/http/pprof"
"strconv"
"strings"
"time"
"github.com/centrifugal/centrifugo/v6/internal/admin"
"github.com/centrifugal/centrifugo/v6/internal/api"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/conninit"
"github.com/centrifugal/centrifugo/v6/internal/health"
"github.com/centrifugal/centrifugo/v6/internal/middleware"
"github.com/centrifugal/centrifugo/v6/internal/swaggerui"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifugo/v6/internal/unihttpstream"
"github.com/centrifugal/centrifugo/v6/internal/unisse"
"github.com/centrifugal/centrifugo/v6/internal/uniws"
"github.com/centrifugal/centrifugo/v6/internal/wt"
"github.com/centrifugal/centrifuge"
"github.com/justinas/alice"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/quic-go/quic-go/http3"
"github.com/quic-go/webtransport-go"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// HandlerFlag is a bit mask of handlers that must be enabled in mux.
type HandlerFlag int
const (
// HandlerWebsocket enables Raw Websocket handler.
HandlerWebsocket HandlerFlag = 1 << iota
// HandlerWebtransport enables Webtransport handler (requires HTTP/3)
HandlerWebtransport
// HandlerAPI enables API handler.
HandlerAPI
// HandlerAdmin enables admin web interface.
HandlerAdmin
// HandlerDebug enables debug handlers.
HandlerDebug
// HandlerPrometheus enables Prometheus handler.
HandlerPrometheus
// HandlerHealth enables Health check endpoint.
HandlerHealth
// HandlerUniWebsocket enables unidirectional websocket endpoint.
HandlerUniWebsocket
// HandlerUniSSE enables unidirectional SSE endpoint.
HandlerUniSSE
// HandlerUniHTTPStream enables unidirectional HTTP stream endpoint.
HandlerUniHTTPStream
// HandlerSSE enables bidirectional SSE endpoint (with emulation layer).
HandlerSSE
// HandlerHTTPStream enables bidirectional HTTP stream endpoint (with emulation layer).
HandlerHTTPStream
// HandlerEmulation handles client-to-server requests in an emulation layer.
HandlerEmulation
// HandlerInit handles connection initialization.
HandlerInit
// HandlerSwagger handles swagger UI.
HandlerSwagger
)
var handlerText = map[HandlerFlag]string{
HandlerWebsocket: "websocket",
HandlerWebtransport: "webtransport",
HandlerAPI: "api",
HandlerAdmin: "admin",
HandlerDebug: "debug",
HandlerPrometheus: "prometheus",
HandlerHealth: "health",
HandlerUniWebsocket: "uni_websocket",
HandlerUniSSE: "uni_sse",
HandlerUniHTTPStream: "uni_http_stream",
HandlerSSE: "sse",
HandlerHTTPStream: "http_stream",
HandlerEmulation: "emulation",
HandlerInit: "init",
HandlerSwagger: "swagger",
}
func (flags HandlerFlag) String() string {
flagsOrdered := []HandlerFlag{HandlerWebsocket, HandlerWebtransport, HandlerHTTPStream, HandlerSSE, HandlerEmulation, HandlerAPI, HandlerAdmin, HandlerPrometheus, HandlerDebug, HandlerHealth, HandlerUniWebsocket, HandlerUniSSE, HandlerUniHTTPStream, HandlerSwagger, HandlerInit}
var endpoints []string
for _, flag := range flagsOrdered {
text, ok := handlerText[flag]
if !ok {
continue
}
if flags&flag != 0 {
endpoints = append(endpoints, text)
}
}
return strings.Join(endpoints, ", ")
}
// Mux returns a mux including set of default handlers for Centrifugo server.
func Mux(
n *centrifuge.Node, cfgContainer *config.Container, apiExecutor *api.Executor, flags HandlerFlag, keepHeadersInContext bool, wtServer *webtransport.Server,
) *http.ServeMux {
mux := http.NewServeMux()
cfg := cfgContainer.Config()
var commonMiddlewares []alice.Constructor
useLoggingMW := zerolog.GlobalLevel() <= zerolog.DebugLevel
if useLoggingMW {
commonMiddlewares = append(commonMiddlewares, middleware.LogRequest)
}
if cfg.Prometheus.Enabled && cfg.Prometheus.InstrumentHTTPHandlers {
commonMiddlewares = append(commonMiddlewares, middleware.HTTPServerInstrumentation)
}
basicMiddlewares := append([]alice.Constructor{}, commonMiddlewares...)
basicChain := alice.New(basicMiddlewares...)
if flags&HandlerDebug != 0 {
mux.Handle(cfg.Debug.HandlerPrefix+"/", basicChain.Then(http.HandlerFunc(pprof.Index)))
mux.Handle(cfg.Debug.HandlerPrefix+"/cmdline", basicChain.Then(http.HandlerFunc(pprof.Cmdline)))
mux.Handle(cfg.Debug.HandlerPrefix+"/profile", basicChain.Then(http.HandlerFunc(pprof.Profile)))
mux.Handle(cfg.Debug.HandlerPrefix+"/symbol", basicChain.Then(http.HandlerFunc(pprof.Symbol)))
mux.Handle(cfg.Debug.HandlerPrefix+"/trace", basicChain.Then(http.HandlerFunc(pprof.Trace)))
}
if flags&HandlerEmulation != 0 {
// register bidirectional emulation endpoint.
emulationMiddlewares := append([]alice.Constructor{}, commonMiddlewares...)
emulationMiddlewares = append(emulationMiddlewares, middleware.NewCORS(getCheckOrigin(cfg)).Middleware)
emulationChain := alice.New(emulationMiddlewares...)
emulationPrefix := strings.TrimRight(cfg.Emulation.HandlerPrefix, "/")
if emulationPrefix == "" {
emulationPrefix = "/"
}
mux.Handle(emulationPrefix, emulationChain.Then(centrifuge.NewEmulationHandler(n, emulationHandlerConfig(cfg))))
}
connMiddlewares := append([]alice.Constructor{}, commonMiddlewares...)
// The ConnLimit middleware enforces both the total connection cap and the
// connection rate limit, so install it when either is configured - otherwise
// connection_rate_limit is silently ignored when connection_limit is 0.
if cfg.Client.ConnectionLimit > 0 || cfg.Client.ConnectionRateLimit > 0 {
connLimitMW := middleware.NewConnLimit(n, cfgContainer)
connMiddlewares = append(connMiddlewares, connLimitMW.Middleware)
}
userIDHTTPHeader := cfg.Client.UserIDHTTPHeader
if userIDHTTPHeader != "" {
connMiddlewares = append(connMiddlewares, middleware.UserHeaderAuth(userIDHTTPHeader))
}
if keepHeadersInContext {
connMiddlewares = append(connMiddlewares, middleware.HeadersToContext)
}
connMiddlewares = append(connMiddlewares, middleware.NewCORS(getCheckOrigin(cfg)).Middleware)
connChain := alice.New(connMiddlewares...)
if flags&HandlerWebsocket != 0 {
// register WebSocket connection endpoint.
wsPrefix := strings.TrimRight(cfg.WebSocket.HandlerPrefix, "/")
if wsPrefix == "" {
wsPrefix = "/"
}
mux.Handle(wsPrefix, connChain.Then(centrifuge.NewWebsocketHandler(n, websocketHandlerConfig(cfg))))
}
if flags&HandlerWebtransport != 0 {
// register WebTransport connection endpoint.
wtPrefix := strings.TrimRight(cfg.WebTransport.HandlerPrefix, "/")
if wtPrefix == "" {
wtPrefix = "/"
}
mux.Handle(wtPrefix, connChain.Then(wt.NewHandler(n, wtServer, cfg.WebTransport, getPingPongConfig(cfg))))
}
if flags&HandlerInit != 0 {
// register connection initialization endpoint.
connInitPrefix := strings.TrimRight(cfg.Init.HandlerPrefix, "/")
if connInitPrefix == "" {
connInitPrefix = "/"
}
mux.Handle(connInitPrefix, middleware.Method(http.MethodGet, connChain.Then(conninit.NewHandler())))
}
if flags&HandlerHTTPStream != 0 {
// register bidirectional HTTP stream connection endpoint.
streamPrefix := strings.TrimRight(cfg.HTTPStream.HandlerPrefix, "/")
if streamPrefix == "" {
streamPrefix = "/"
}
mux.Handle(streamPrefix, connChain.Then(centrifuge.NewHTTPStreamHandler(n, httpStreamHandlerConfig(cfg))))
}
if flags&HandlerSSE != 0 {
// register bidirectional SSE connection endpoint.
ssePrefix := strings.TrimRight(cfg.SSE.HandlerPrefix, "/")
if ssePrefix == "" {
ssePrefix = "/"
}
mux.Handle(ssePrefix, connChain.Then(centrifuge.NewSSEHandler(n, sseHandlerConfig(cfg))))
}
if flags&HandlerUniWebsocket != 0 {
// register unidirectional WebSocket connection endpoint.
wsPrefix := strings.TrimRight(cfg.UniWS.HandlerPrefix, "/")
if wsPrefix == "" {
wsPrefix = "/"
}
mux.Handle(wsPrefix, connChain.Then(
uniws.NewHandler(n, cfg.UniWS, getCheckOrigin(cfg), getPingPongConfig(cfg))))
}
if flags&HandlerUniSSE != 0 {
// register unidirectional SSE connection endpoint.
ssePrefix := strings.TrimRight(cfg.UniSSE.HandlerPrefix, "/")
if ssePrefix == "" {
ssePrefix = "/"
}
mux.Handle(ssePrefix, connChain.Then(unisse.NewHandler(n, cfg.UniSSE, getPingPongConfig(cfg))))
}
if flags&HandlerUniHTTPStream != 0 {
// register unidirectional HTTP stream connection endpoint.
streamPrefix := strings.TrimRight(cfg.UniHTTPStream.HandlerPrefix, "/")
if streamPrefix == "" {
streamPrefix = "/"
}
mux.Handle(streamPrefix, connChain.Then(unihttpstream.NewHandler(n, cfg.UniHTTPStream, getPingPongConfig(cfg))))
}
if flags&HandlerAPI != 0 {
// register HTTP API endpoints.
httpErrorMode, err := tools.OptionalStringChoice(cfg.HttpAPI.ErrorMode, []string{config.TransportErrorMode})
if err != nil {
log.Fatal().Err(err).Msg("error in config")
}
useOpenTelemetry := cfg.OpenTelemetry.Enabled && cfg.OpenTelemetry.API
apiHandler := api.NewHandler(n, apiExecutor, api.Config{
UseOpenTelemetry: useOpenTelemetry,
UseTransportErrorMode: httpErrorMode == config.TransportErrorMode,
})
apiPrefix := strings.TrimRight(cfg.HttpAPI.HandlerPrefix, "/")
if apiPrefix == "" {
apiPrefix = "/"
}
apiChain := func(op string) alice.Chain {
apiMiddlewares := append([]alice.Constructor{}, commonMiddlewares...)
otelHandler := middleware.NewOpenTelemetryHandler(op, nil)
if useOpenTelemetry {
apiMiddlewares = append(apiMiddlewares, otelHandler.Middleware)
}
apiMiddlewares = append(apiMiddlewares, middleware.Post)
if !cfg.HttpAPI.Insecure {
apiMiddlewares = append(apiMiddlewares, middleware.NewAPIKeyAuth(cfg.HttpAPI.Key).Middleware)
}
apiChain := alice.New(apiMiddlewares...)
return apiChain
}
mux.Handle(apiPrefix, apiChain(apiPrefix).Then(apiHandler.OldRoute()))
if apiPrefix != "/" {
for path, handler := range apiHandler.Routes() {
handlePath := apiPrefix + path
mux.Handle(handlePath, apiChain(handlePath).Then(handler))
}
} else {
for path, handler := range apiHandler.Routes() {
mux.Handle(path, apiChain(path).Then(handler))
}
}
}
if flags&HandlerSwagger != 0 {
// register Swagger UI endpoint.
swaggerPrefix := strings.TrimRight(cfg.Swagger.HandlerPrefix, "/") + "/"
if swaggerPrefix == "" {
swaggerPrefix = "/"
}
mux.Handle(swaggerPrefix, basicChain.Then(http.StripPrefix(swaggerPrefix, http.FileServer(swaggerui.FS))))
}
if flags&HandlerPrometheus != 0 {
// register Prometheus metrics export endpoint.
prometheusPrefix := strings.TrimRight(cfg.Prometheus.HandlerPrefix, "/")
if prometheusPrefix == "" {
prometheusPrefix = "/"
}
mux.Handle(prometheusPrefix, basicChain.Then(promhttp.Handler()))
}
if flags&HandlerAdmin != 0 {
adminPrefix := strings.TrimRight(cfg.Admin.HandlerPrefix, "/")
mux.Handle(adminPrefix+"/", basicChain.Then(admin.NewHandler(n, apiExecutor, cfg.Admin)))
}
if flags&HandlerHealth != 0 {
healthPrefix := strings.TrimRight(cfg.Health.HandlerPrefix, "/")
if healthPrefix == "" {
healthPrefix = "/"
}
mux.Handle(healthPrefix, basicChain.Then(health.NewHandler(n, health.Config{})))
}
return mux
}
func getPingPongConfig(cfg config.Config) centrifuge.PingPongConfig {
pingInterval := cfg.Client.PingInterval
pongTimeout := cfg.Client.PongTimeout
if pingInterval <= pongTimeout {
log.Fatal().Str("ping_interval", pingInterval.String()).Str("pong_timeout", pongTimeout.String()).
Msg("ping_interval must be greater than pong_timeout")
}
return centrifuge.PingPongConfig{
PingInterval: pingInterval.ToDuration(),
PongTimeout: pongTimeout.ToDuration(),
}
}
func websocketHandlerConfig(appCfg config.Config) centrifuge.WebsocketConfig {
cfg := centrifuge.WebsocketConfig{}
cfg.Compression = appCfg.WebSocket.Compression
cfg.CompressionLevel = appCfg.WebSocket.CompressionLevel
cfg.CompressionMinSize = appCfg.WebSocket.CompressionMinSize
cfg.ReadBufferSize = appCfg.WebSocket.ReadBufferSize
cfg.WriteBufferSize = appCfg.WebSocket.WriteBufferSize
cfg.UseWriteBufferPool = appCfg.WebSocket.UseWriteBufferPool
cfg.WriteTimeout = appCfg.WebSocket.WriteTimeout.ToDuration()
cfg.MessageSizeLimit = appCfg.WebSocket.MessageSizeLimit
cfg.DecompressedMessageSizeLimit = appCfg.WebSocket.DecompressedMessageSizeLimit
cfg.CheckOrigin = getCheckOrigin(appCfg)
cfg.PingPongConfig = getPingPongConfig(appCfg)
return cfg
}
func httpStreamHandlerConfig(appCfg config.Config) centrifuge.HTTPStreamConfig {
return centrifuge.HTTPStreamConfig{
MaxRequestBodySize: appCfg.HTTPStream.MaxRequestBodySize,
PingPongConfig: getPingPongConfig(appCfg),
}
}
func sseHandlerConfig(appCfg config.Config) centrifuge.SSEConfig {
return centrifuge.SSEConfig{
MaxRequestBodySize: appCfg.SSE.MaxRequestBodySize,
PingPongConfig: getPingPongConfig(appCfg),
}
}
func emulationHandlerConfig(cfg config.Config) centrifuge.EmulationConfig {
return centrifuge.EmulationConfig{
MaxRequestBodySize: cfg.Emulation.MaxRequestBodySize,
}
}
func runHTTPServers(
n *centrifuge.Node, cfgContainer *config.Container, apiExecutor *api.Executor, keepHeadersInContext bool,
) ([]*http.Server, error) {
cfg := cfgContainer.Config()
debug := cfg.Debug.Enabled
useAdmin := cfg.Admin.Enabled
usePrometheus := cfg.Prometheus.Enabled
useHealth := cfg.Health.Enabled
useSwagger := cfg.Swagger.Enabled
useConnInit := cfg.Init.Enabled
adminExternal := cfg.Admin.External
apiExternal := cfg.HttpAPI.External
apiDisabled := cfg.HttpAPI.Disabled
httpAddress := cfg.HTTP.Address
httpPort := strconv.Itoa(cfg.HTTP.Port)
httpInternalAddress := cfg.HTTP.InternalAddress
httpInternalPort := cfg.HTTP.InternalPort
if httpInternalAddress == "" && httpAddress != "" {
// If custom internal address not explicitly set we try to reuse main
// address for internal endpoints too.
httpInternalAddress = httpAddress
}
if httpInternalPort == "" {
// If custom internal port not set we use default http port for
// internal endpoints too.
httpInternalPort = httpPort
}
// addrToHandlerFlags contains mapping between HTTP server address and
// handler flags to serve on this address.
addrToHandlerFlags := map[string]HandlerFlag{}
var portFlags HandlerFlag
externalAddr := net.JoinHostPort(httpAddress, httpPort)
portFlags = addrToHandlerFlags[externalAddr]
if !cfg.WebSocket.Disabled {
portFlags |= HandlerWebsocket
}
if cfg.WebTransport.Enabled {
if !cfg.HTTP.HTTP3.Enabled {
log.Fatal().Msg("can not enable webtransport without experimental HTTP/3")
}
portFlags |= HandlerWebtransport
}
if cfg.SSE.Enabled {
portFlags |= HandlerSSE
}
if cfg.HTTPStream.Enabled {
portFlags |= HandlerHTTPStream
}
if cfg.SSE.Enabled || cfg.HTTPStream.Enabled {
portFlags |= HandlerEmulation
}
if useAdmin && adminExternal {
portFlags |= HandlerAdmin
}
if !apiDisabled && apiExternal {
portFlags |= HandlerAPI
}
if cfg.UniWS.Enabled {
portFlags |= HandlerUniWebsocket
}
if cfg.UniSSE.Enabled {
portFlags |= HandlerUniSSE
}
if cfg.UniHTTPStream.Enabled {
portFlags |= HandlerUniHTTPStream
}
if useConnInit {
portFlags |= HandlerInit
}
addrToHandlerFlags[externalAddr] = portFlags
internalAddr := net.JoinHostPort(httpInternalAddress, httpInternalPort)
portFlags = addrToHandlerFlags[internalAddr]
if !apiDisabled && !apiExternal {
portFlags |= HandlerAPI
}
if useAdmin && !adminExternal {
portFlags |= HandlerAdmin
}
if usePrometheus {
portFlags |= HandlerPrometheus
}
if useSwagger {
portFlags |= HandlerSwagger
}
if debug {
portFlags |= HandlerDebug
}
if useHealth {
portFlags |= HandlerHealth
}
addrToHandlerFlags[internalAddr] = portFlags
var servers []*http.Server
tlsConfig, err := GetTLSConfig(cfg)
if err != nil {
log.Fatal().Err(err).Msg("can not get TLS config")
}
var internalTLSConfig *tls.Config
if cfg.HTTP.InternalTLS.Enabled {
internalTLSConfig, err = cfg.HTTP.InternalTLS.ToGoTLSConfig("internal_tls")
if err != nil {
log.Fatal().Err(err).Msg("can not get internal TLS config")
}
}
// Iterate over port-to-flags mapping and start HTTP servers
// on separate ports serving handlers specified in flags.
for addr, handlerFlags := range addrToHandlerFlags {
addr := addr
if handlerFlags == 0 {
continue
}
var addrTLSConfig *tls.Config
if !cfg.HTTP.TLSExternal || addr == externalAddr {
addrTLSConfig = tlsConfig
}
if addr != externalAddr && cfg.HTTP.InternalTLS.Enabled {
addrTLSConfig = internalTLSConfig
}
useHTTP3 := cfg.HTTP.HTTP3.Enabled && addr == externalAddr
useWebtransport := useHTTP3 && handlerFlags&HandlerWebtransport != 0
var wtServer *webtransport.Server
if useWebtransport {
wtServer = &webtransport.Server{
CheckOrigin: getCheckOrigin(cfg),
}
}
mux := Mux(n, cfgContainer, apiExecutor, handlerFlags, keepHeadersInContext, wtServer)
var h3Server *http3.Server
if useHTTP3 {
h3Server = &http3.Server{
Addr: addr,
TLSConfig: addrTLSConfig,
Handler: mux,
}
if useWebtransport {
webtransport.ConfigureHTTP3Server(h3Server)
wtServer.H3 = h3Server
}
}
var protoSuffix string
if useHTTP3 {
protoSuffix = " with HTTP/3 (experimental)"
}
useH2C := cfg.HTTP.H2CExternal && addr == externalAddr && addrTLSConfig == nil
if useH2C {
protoSuffix = " with HTTP/2 cleartext"
}
log.Info().Msgf("serving %s endpoints on %s%s", handlerFlags, addr, protoSuffix)
var handler http.Handler = mux
if useHTTP3 {
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = h3Server.SetQUICHeaders(w.Header())
mux.ServeHTTP(w, r)
})
}
server := &http.Server{
Addr: addr,
Handler: handler,
TLSConfig: addrTLSConfig,
ErrorLog: stdlog.New(&httpErrorLogWriter{Logger: log.Logger}, "", 0),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 10 * time.Second,
}
if useH2C {
protocols := new(http.Protocols)
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true)
server.Protocols = protocols
}
servers = append(servers, server)
go func() {
if useHTTP3 {
if addrTLSConfig == nil {
log.Fatal().Msg("HTTP/3 requires TLS configured")
}
if cfg.HTTP.TLSAutocert.Enabled {
log.Fatal().Msg("can not use HTTP/3 with autocert")
}
udpAddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
log.Fatal().Err(err).Msg("can not start HTTP/3, resolve UDP")
}
udpConn, err := net.ListenUDP("udp", udpAddr)
if err != nil {
log.Fatal().Err(err).Msg("can not start HTTP/3, listen UDP")
}
defer func() { _ = udpConn.Close() }()
tcpAddr, err := net.ResolveTCPAddr("tcp", addr)
if err != nil {
log.Fatal().Err(err).Msg("can not start HTTP/3, resolve TCP")
}
tcpConn, err := net.ListenTCP("tcp", tcpAddr)
if err != nil {
log.Fatal().Err(err).Msg("can not start HTTP/3, listen TCP")
}
defer func() { _ = tcpConn.Close() }()
tlsConn := tls.NewListener(tcpConn, addrTLSConfig)
defer func() { _ = tlsConn.Close() }()
hErr := make(chan error)
qErr := make(chan error)
go func() {
hErr <- server.Serve(tlsConn)
}()
go func() {
if useWebtransport {
qErr <- wtServer.Serve(udpConn)
} else {
qErr <- h3Server.Serve(udpConn)
}
}()
select {
case err := <-hErr:
if useWebtransport {
_ = wtServer.Close()
} else {
_ = h3Server.Close()
}
if !errors.Is(err, http.ErrServerClosed) {
log.Fatal().Err(err).Msg("error ListenAndServe")
}
case err := <-qErr:
// Cannot close the HTTP server or wait for requests to complete properly.
log.Fatal().Err(err).Msg("error ListenAndServe HTTP/3")
}
} else {
if addrTLSConfig != nil {
if err := server.ListenAndServeTLS("", ""); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
log.Fatal().Err(err).Msg("error ListenAndServeTLS")
}
}
} else {
if err := server.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
log.Fatal().Err(err).Msgf("error ListenAndServe")
}
}
}
}
}()
}
return servers, nil
}

122
internal/app/node.go Normal file
View File

@@ -0,0 +1,122 @@
package app
import (
"os"
"strconv"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/logging"
"github.com/centrifugal/centrifuge"
)
func centrifugeNodeConfig(version string, edition string, cfgContainer *config.Container, logHandler centrifuge.LogHandler) centrifuge.Config {
appCfg := cfgContainer.Config()
cfg := centrifuge.Config{}
cfg.Version = version + " " + strings.ToUpper(edition)
cfg.Metrics = centrifuge.MetricsConfig{
MetricsNamespace: "centrifugo",
EnableRecoveredPublicationsHistogram: appCfg.Prometheus.RecoveredPublicationsHistogram,
EnableNativeHistograms: appCfg.Prometheus.NativeHistograms,
}
cfg.Name = nodeName(appCfg)
cfg.ChannelMaxLength = appCfg.Channel.MaxLength
cfg.ClientPresenceUpdateInterval = appCfg.Client.PresenceUpdateInterval.ToDuration()
cfg.ClientExpiredCloseDelay = appCfg.Client.ExpiredCloseDelay.ToDuration()
cfg.ClientExpiredSubCloseDelay = appCfg.Client.ExpiredSubCloseDelay.ToDuration()
cfg.ClientStaleCloseDelay = appCfg.Client.StaleCloseDelay.ToDuration()
cfg.ClientQueueMaxSize = appCfg.Client.QueueMaxSize
cfg.ClientChannelLimit = appCfg.Client.ChannelLimit
cfg.ClientChannelPositionCheckDelay = appCfg.Client.ChannelPositionCheckDelay.ToDuration()
cfg.ClientChannelPositionMaxTimeLag = appCfg.Client.ChannelPositionMaxTimeLag.ToDuration()
cfg.UserConnectionLimit = appCfg.Client.UserConnectionLimit
cfg.NodeInfoMetricsAggregateInterval = appCfg.Node.InfoMetricsAggregateInterval.ToDuration()
cfg.HistoryMaxPublicationLimit = appCfg.Client.HistoryMaxPublicationLimit
cfg.RecoveryMaxPublicationLimit = appCfg.Client.RecoveryMaxPublicationLimit
cfg.Map.GetMapChannelOptions = func(channel string) centrifuge.MapChannelOptions {
_, _, chOpts, ok, err := cfgContainer.ChannelOptions(channel)
if err != nil || !ok {
return centrifuge.MapChannelOptions{}
}
var mode centrifuge.MapMode
switch chOpts.Map.Mode {
case "ephemeral":
mode = centrifuge.MapModeEphemeral
case "recoverable":
mode = centrifuge.MapModeRecoverable
case "persistent":
mode = centrifuge.MapModePersistent
}
return centrifuge.MapChannelOptions{
Mode: mode,
KeyTTL: chOpts.Map.KeyTTL.ToDuration(),
StreamSize: chOpts.Map.StreamSize,
StreamTTL: chOpts.Map.StreamTTL.ToDuration(),
MetaTTL: chOpts.Map.MetaTTL.ToDuration(),
DefaultPageSize: chOpts.Map.DefaultPageSize,
MinPageSize: chOpts.Map.MinPageSize,
MaxPageSize: chOpts.Map.MaxPageSize,
LiveTransitionMaxPublicationLimit: chOpts.Map.LiveTransitionMaxPublicationLimit,
SubscribeCatchUpTimeout: chOpts.Map.SubscribeCatchUpTimeout.ToDuration(),
}
}
hasSharedPoll := appCfg.Channel.WithoutNamespace.SubscriptionType == "shared_poll"
if !hasSharedPoll {
for _, ns := range appCfg.Channel.Namespaces {
if ns.SubscriptionType == "shared_poll" {
hasSharedPoll = true
break
}
}
}
if hasSharedPoll {
cfg.SharedPoll.ConcurrencyLimit = appCfg.SharedPoll.ConcurrencyLimit
cfg.SharedPoll.GetSharedPollChannelOptions = func(channel string) (centrifuge.SharedPollChannelOptions, bool) {
_, _, chOpts, ok, err := cfgContainer.ChannelOptions(channel)
if err != nil || !ok {
return centrifuge.SharedPollChannelOptions{}, false
}
if chOpts.SubscriptionType != "shared_poll" {
return centrifuge.SharedPollChannelOptions{}, false
}
return centrifuge.SharedPollChannelOptions{
MaxKeysPerConnection: chOpts.SharedPoll.MaxKeysPerConnection,
RefreshInterval: chOpts.SharedPoll.RefreshInterval.ToDuration(),
RefreshBatchSize: chOpts.SharedPoll.RefreshBatchSize,
Mode: chOpts.SharedPoll.Mode,
ChannelShutdownDelay: chOpts.SharedPoll.ChannelShutdownDelay.ToDuration(),
TrackExpiredExtraDelay: chOpts.SharedPoll.TrackExpiredExtraDelay.ToDuration(),
PublishEnabled: chOpts.SharedPoll.PublishEnabled,
}, true
}
}
cfg.HistoryMetaTTL = appCfg.Channel.HistoryMetaTTL.ToDuration()
cfg.ClientConnectIncludeServerTime = appCfg.Client.ConnectIncludeServerTime
cfg.LogLevel = logging.CentrifugeLogLevel(strings.ToLower(appCfg.Log.Level))
cfg.LogHandler = logHandler
if appCfg.Client.ConnectCodeToUnidirectionalDisconnect.Enabled {
uniCodeTransforms := make(map[uint32]centrifuge.Disconnect)
for _, transform := range appCfg.Client.ConnectCodeToUnidirectionalDisconnect.Transforms {
uniCodeTransforms[transform.Code] = centrifuge.Disconnect{Code: transform.To.Code, Reason: transform.To.Reason}
}
cfg.UnidirectionalCodeToDisconnect = uniCodeTransforms
}
return cfg
}
// nodeName returns a name for this Centrifugo node. If no name provided
// in configuration then it constructs node name based on hostname and port
func nodeName(cfg config.Config) string {
name := cfg.Node.Name
if name != "" {
return name
}
port := strconv.Itoa(cfg.HTTP.Port)
var hostname string
hostname, err := os.Hostname()
if err != nil {
hostname = "?"
}
return hostname + "_" + port
}

49
internal/app/origin.go Normal file
View File

@@ -0,0 +1,49 @@
package app
import (
"net/http"
"sync"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/origin"
"github.com/rs/zerolog/log"
)
var warnAllowedOriginsOnce sync.Once
func getCheckOrigin(cfg config.Config) func(r *http.Request) bool {
allowedOrigins := cfg.Client.AllowedOrigins
if len(allowedOrigins) == 0 {
return func(r *http.Request) bool {
// Only allow connections without Origin in this case.
originHeader := r.Header.Get("Origin")
if originHeader == "" {
return true
}
log.Info().Str("origin", originHeader).Msg("request Origin is not authorized due to empty allowed_origins")
return false
}
}
originChecker, err := origin.NewPatternChecker(allowedOrigins)
if err != nil {
log.Fatal().Err(err).Msg("error creating origin checker")
}
if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
// Fast path for *.
warnAllowedOriginsOnce.Do(func() {
log.Warn().Msg("usage of allowed_origins * is discouraged for security reasons, consider setting exact list of origins")
})
return func(r *http.Request) bool {
return true
}
}
return func(r *http.Request) bool {
ok := originChecker.Check(r)
if !ok {
log.Info().Str("origin", r.Header.Get("Origin")).Strs("allowed_origins", allowedOrigins).Msg("request Origin is not authorized")
return false
}
return true
}
}

533
internal/app/proxy.go Normal file
View File

@@ -0,0 +1,533 @@
package app
import (
"fmt"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/client"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/proxy"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/rs/zerolog/log"
)
func buildProxyMap(cfg config.Config) (*client.ProxyMap, bool, error) {
proxyMap := &client.ProxyMap{
ConnectProxy: nil,
RefreshProxy: nil,
RpcProxies: map[string]proxy.RPCProxy{},
PublishProxies: map[string]proxy.PublishProxy{},
SubscribeProxies: map[string]proxy.SubscribeProxy{},
SubRefreshProxies: map[string]proxy.SubRefreshProxy{},
SubscribeStreamProxies: map[string]*proxy.SubscribeStreamProxy{},
MapPublishProxies: map[string]proxy.MapPublishProxy{},
MapRemoveProxies: map[string]proxy.MapRemoveProxy{},
SharedPollRefreshProxies: map[string]*proxy.SharedPollRefreshHandler{},
}
var keepHeadersInContext bool
var keepEmulatedHeadersInContext bool
var err error
var proxyFound bool
if cfg.Client.Proxy.Connect.Enabled {
p := cfg.Client.Proxy.Connect
proxyMap.ConnectProxy, err = proxy.GetConnectProxy("connect", cfg.Client.Proxy.Connect.Proxy)
if err != nil {
return nil, false, fmt.Errorf("error creating connect proxy: %w", err)
}
log.Info().Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("connect proxy enabled")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
if cfg.Client.Proxy.Refresh.Enabled {
p := cfg.Client.Proxy.Refresh
proxyMap.RefreshProxy, err = proxy.GetRefreshProxy("refresh", cfg.Client.Proxy.Refresh.Proxy)
if err != nil {
return nil, false, fmt.Errorf("error creating refresh proxy: %w", err)
}
log.Info().Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("refresh proxy enabled")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
namedProxies := make(map[string]configtypes.Proxy)
for _, p := range cfg.Proxies {
namedProxies[p.Name] = p.Proxy
}
subscribeProxyEnabled := cfg.Channel.WithoutNamespace.SubscribeProxyEnabled
subscribeProxyName := cfg.Channel.WithoutNamespace.SubscribeProxyName
if subscribeProxyEnabled {
var p proxy.Config
if subscribeProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.Subscribe
} else {
p, proxyFound = namedProxies[subscribeProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("subscribe proxy not found: %s", subscribeProxyName)
}
}
if _, ok := proxyMap.SubscribeProxies[subscribeProxyName]; !ok {
sp, err := proxy.GetSubscribeProxy(subscribeProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating subscribe proxy %s: %w", subscribeProxyName, err)
}
proxyMap.SubscribeProxies[subscribeProxyName] = sp
}
log.Info().Str("proxy_name", subscribeProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("subscribe proxy enabled for channels without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
publishProxyEnabled := cfg.Channel.WithoutNamespace.PublishProxyEnabled
publishProxyName := cfg.Channel.WithoutNamespace.PublishProxyName
if publishProxyEnabled {
var p proxy.Config
if publishProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.Publish
} else {
p, proxyFound = namedProxies[publishProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("publish proxy not found: %s", publishProxyName)
}
}
if _, ok := proxyMap.PublishProxies[publishProxyName]; !ok {
pp, err := proxy.GetPublishProxy(publishProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating publish proxy %s: %w", publishProxyName, err)
}
proxyMap.PublishProxies[publishProxyName] = pp
}
log.Info().Str("proxy_name", publishProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("publish proxy enabled for channels without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
subRefreshProxyEnabled := cfg.Channel.WithoutNamespace.SubRefreshProxyEnabled
subRefreshProxyName := cfg.Channel.WithoutNamespace.SubRefreshProxyName
if subRefreshProxyEnabled {
var p proxy.Config
if subRefreshProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.SubRefresh
} else {
p, proxyFound = namedProxies[subRefreshProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("sub refresh proxy not found: %s", subRefreshProxyName)
}
}
if _, ok := proxyMap.SubRefreshProxies[subRefreshProxyName]; !ok {
srp, err := proxy.GetSubRefreshProxy(subRefreshProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating sub refresh proxy %s: %w", subRefreshProxyName, err)
}
proxyMap.SubRefreshProxies[subRefreshProxyName] = srp
}
log.Info().Str("proxy_name", subRefreshProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("sub refresh proxy enabled for channels without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
subscribeStreamProxyEnabled := cfg.Channel.WithoutNamespace.SubscribeStreamProxyEnabled
subscribeStreamProxyName := cfg.Channel.WithoutNamespace.SubscribeStreamProxyName
if subscribeStreamProxyEnabled {
var p proxy.Config
if subscribeStreamProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.SubscribeStream
} else {
p, proxyFound = namedProxies[subscribeStreamProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("subscribe stream proxy not found: %s", subscribeStreamProxyName)
}
}
if strings.HasPrefix(p.Endpoint, "http") {
log.Fatal().Str("name", subscribeStreamProxyName).Msg("error creating subscribe stream proxy – only GRPC endpoints supported")
}
if _, ok := proxyMap.SubscribeStreamProxies[subscribeStreamProxyName]; !ok {
sp, err := proxy.NewSubscribeStreamProxy(subscribeStreamProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating subscribe stream proxy %s: %w", subscribeStreamProxyName, err)
}
proxyMap.SubscribeStreamProxies[subscribeStreamProxyName] = sp
}
log.Info().Str("proxy_name", subscribeStreamProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("subscribe stream proxy enabled for channels without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
mapPublishProxyEnabled := cfg.Channel.WithoutNamespace.Map.PublishProxyEnabled
mapPublishProxyName := cfg.Channel.WithoutNamespace.Map.PublishProxyName
if mapPublishProxyEnabled {
var p proxy.Config
if mapPublishProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.MapPublish
} else {
p, proxyFound = namedProxies[mapPublishProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("map publish proxy not found: %s", mapPublishProxyName)
}
}
if _, ok := proxyMap.MapPublishProxies[mapPublishProxyName]; !ok {
mpp, err := proxy.GetMapPublishProxy(mapPublishProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating map publish proxy %s: %w", mapPublishProxyName, err)
}
proxyMap.MapPublishProxies[mapPublishProxyName] = mpp
}
log.Info().Str("proxy_name", mapPublishProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("map publish proxy enabled for channels without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
mapRemoveProxyEnabled := cfg.Channel.WithoutNamespace.Map.RemoveProxyEnabled
mapRemoveProxyName := cfg.Channel.WithoutNamespace.Map.RemoveProxyName
if mapRemoveProxyEnabled {
var p proxy.Config
if mapRemoveProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.MapRemove
} else {
p, proxyFound = namedProxies[mapRemoveProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("map remove proxy not found: %s", mapRemoveProxyName)
}
}
if _, ok := proxyMap.MapRemoveProxies[mapRemoveProxyName]; !ok {
mrp, err := proxy.GetMapRemoveProxy(mapRemoveProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating map remove proxy %s: %w", mapRemoveProxyName, err)
}
proxyMap.MapRemoveProxies[mapRemoveProxyName] = mrp
}
log.Info().Str("proxy_name", mapRemoveProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("map remove proxy enabled for channels without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
for _, ns := range cfg.Channel.Namespaces {
subscribeProxyEnabled := ns.SubscribeProxyEnabled
subscribeProxyName := ns.SubscribeProxyName
if subscribeProxyEnabled {
var p proxy.Config
if subscribeProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.Subscribe
} else {
p, proxyFound = namedProxies[subscribeProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("subscribe proxy not found: %s", subscribeProxyName)
}
}
if _, ok := proxyMap.SubscribeProxies[subscribeProxyName]; !ok {
sp, err := proxy.GetSubscribeProxy(subscribeProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating subscribe proxy %s: %w", subscribeProxyName, err)
}
proxyMap.SubscribeProxies[subscribeProxyName] = sp
}
log.Info().Str("proxy_name", subscribeProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("subscribe proxy enabled for channels in namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
publishProxyEnabled := ns.PublishProxyEnabled
publishProxyName := ns.PublishProxyName
if publishProxyEnabled {
var p proxy.Config
if publishProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.Publish
} else {
p, proxyFound = namedProxies[publishProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("publish proxy not found: %s", publishProxyName)
}
}
if _, ok := proxyMap.PublishProxies[publishProxyName]; !ok {
pp, err := proxy.GetPublishProxy(publishProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating publish proxy %s: %w", publishProxyName, err)
}
proxyMap.PublishProxies[publishProxyName] = pp
}
log.Info().Str("proxy_name", publishProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("publish proxy enabled for channels in namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
subRefreshProxyEnabled := ns.SubRefreshProxyEnabled
subRefreshProxyName := ns.SubRefreshProxyName
if subRefreshProxyEnabled {
var p proxy.Config
if subRefreshProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.SubRefresh
} else {
p, proxyFound = namedProxies[subRefreshProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("sub refresh proxy not found: %s", subRefreshProxyName)
}
}
if _, ok := proxyMap.SubRefreshProxies[subRefreshProxyName]; !ok {
srp, err := proxy.GetSubRefreshProxy(subRefreshProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating sub refresh proxy %s: %w", subRefreshProxyName, err)
}
proxyMap.SubRefreshProxies[subRefreshProxyName] = srp
}
log.Info().Str("proxy_name", subRefreshProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("sub refresh proxy enabled for channels in namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
subscribeStreamProxyEnabled := ns.SubscribeStreamProxyEnabled
subscribeStreamProxyName := ns.SubscribeStreamProxyName
if subscribeStreamProxyEnabled {
var p proxy.Config
if subscribeStreamProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.SubscribeStream
} else {
p, proxyFound = namedProxies[subscribeStreamProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("subscribe stream proxy not found: %s", subscribeStreamProxyName)
}
}
if strings.HasPrefix(p.Endpoint, "http") {
return nil, false, fmt.Errorf("error creating subscribe stream proxy %s only GRPC endpoints supported", subscribeStreamProxyName)
}
if _, ok := proxyMap.SubscribeStreamProxies[subscribeStreamProxyName]; !ok {
sp, err := proxy.NewSubscribeStreamProxy(subscribeStreamProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating subscribe stream proxy %s: %w", subscribeStreamProxyName, err)
}
proxyMap.SubscribeStreamProxies[subscribeStreamProxyName] = sp
}
log.Info().Str("proxy_name", subscribeStreamProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("subscribe stream proxy enabled for channels in namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
mapPublishProxyEnabled := ns.Map.PublishProxyEnabled
mapPublishProxyName := ns.Map.PublishProxyName
if mapPublishProxyEnabled {
var p proxy.Config
if mapPublishProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.MapPublish
} else {
p, proxyFound = namedProxies[mapPublishProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("map publish proxy not found: %s", mapPublishProxyName)
}
}
if _, ok := proxyMap.MapPublishProxies[mapPublishProxyName]; !ok {
mpp, err := proxy.GetMapPublishProxy(mapPublishProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating map publish proxy %s: %w", mapPublishProxyName, err)
}
proxyMap.MapPublishProxies[mapPublishProxyName] = mpp
}
log.Info().Str("proxy_name", mapPublishProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("map publish proxy enabled for channels in namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
mapRemoveProxyEnabled := ns.Map.RemoveProxyEnabled
mapRemoveProxyName := ns.Map.RemoveProxyName
if mapRemoveProxyEnabled {
var p proxy.Config
if mapRemoveProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.MapRemove
} else {
p, proxyFound = namedProxies[mapRemoveProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("map remove proxy not found: %s", mapRemoveProxyName)
}
}
if _, ok := proxyMap.MapRemoveProxies[mapRemoveProxyName]; !ok {
mrp, err := proxy.GetMapRemoveProxy(mapRemoveProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating map remove proxy %s: %w", mapRemoveProxyName, err)
}
proxyMap.MapRemoveProxies[mapRemoveProxyName] = mrp
}
log.Info().Str("proxy_name", mapRemoveProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("map remove proxy enabled for channels in namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
if ns.SubscriptionType == "shared_poll" {
sharedPollProxyName := ns.SharedPoll.ProxyName
if sharedPollProxyName == "" {
sharedPollProxyName = config.DefaultProxyName
}
var p proxy.Config
if sharedPollProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.SharedPollRefresh
} else {
p, proxyFound = namedProxies[sharedPollProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("shared poll refresh proxy not found: %s", sharedPollProxyName)
}
}
if _, ok := proxyMap.SharedPollRefreshProxies[sharedPollProxyName]; !ok {
sp, err := proxy.GetSharedPollRefreshProxy(sharedPollProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating shared poll refresh proxy %s: %w", sharedPollProxyName, err)
}
handler := proxy.NewSharedPollRefreshHandler(proxy.SharedPollRefreshHandlerConfig{
Proxy: sp,
Name: sharedPollProxyName,
})
proxyMap.SharedPollRefreshProxies[sharedPollProxyName] = handler
}
log.Info().Str("proxy_name", sharedPollProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("shared poll refresh proxy enabled for channels in namespace")
}
}
// Also check without-namespace channels for shared poll proxy.
if cfg.Channel.WithoutNamespace.SubscriptionType == "shared_poll" {
sharedPollProxyName := cfg.Channel.WithoutNamespace.SharedPoll.ProxyName
if sharedPollProxyName == "" {
sharedPollProxyName = config.DefaultProxyName
}
var p proxy.Config
if sharedPollProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.SharedPollRefresh
} else {
p, proxyFound = namedProxies[sharedPollProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("shared poll refresh proxy not found: %s", sharedPollProxyName)
}
}
if _, ok := proxyMap.SharedPollRefreshProxies[sharedPollProxyName]; !ok {
sp, err := proxy.GetSharedPollRefreshProxy(sharedPollProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating shared poll refresh proxy %s: %w", sharedPollProxyName, err)
}
handler := proxy.NewSharedPollRefreshHandler(proxy.SharedPollRefreshHandlerConfig{
Proxy: sp,
Name: sharedPollProxyName,
})
proxyMap.SharedPollRefreshProxies[sharedPollProxyName] = handler
}
log.Info().Str("proxy_name", sharedPollProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("shared poll refresh proxy enabled for channels without namespace")
}
rpcProxyEnabled := cfg.RPC.WithoutNamespace.ProxyEnabled
rpcProxyName := cfg.RPC.WithoutNamespace.ProxyName
if rpcProxyEnabled {
var p proxy.Config
if rpcProxyName == config.DefaultProxyName {
p = cfg.RPC.Proxy
} else {
p, proxyFound = namedProxies[rpcProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("rpc proxy not found: %s", rpcProxyName)
}
}
if _, ok := proxyMap.RpcProxies[rpcProxyName]; !ok {
rp, err := proxy.GetRpcProxy(rpcProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating rpc proxy %s: %w", rpcProxyName, err)
}
proxyMap.RpcProxies[rpcProxyName] = rp
}
log.Info().Str("proxy_name", rpcProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("RPC proxy enabled for methods without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
for _, ns := range cfg.RPC.Namespaces {
rpcProxyEnabled := ns.ProxyEnabled
rpcProxyName := ns.ProxyName
if rpcProxyEnabled {
var p proxy.Config
if rpcProxyName == config.DefaultProxyName {
p = cfg.RPC.Proxy
} else {
p, proxyFound = namedProxies[rpcProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("rpc proxy not found: %s", rpcProxyName)
}
}
if _, ok := proxyMap.RpcProxies[rpcProxyName]; !ok {
rp, err := proxy.GetRpcProxy(rpcProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating rpc proxy %s: %w", rpcProxyName, err)
}
proxyMap.RpcProxies[rpcProxyName] = rp
}
log.Info().Str("proxy_name", rpcProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("RPC proxy enabled for namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
}
proxyMap.UsesEmulatedHeaders = keepEmulatedHeadersInContext
return proxyMap, keepHeadersInContext, nil
}

445
internal/app/run.go Normal file
View File

@@ -0,0 +1,445 @@
package app
import (
"context"
"crypto/fips140"
"errors"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
"github.com/centrifugal/centrifugo/v6/internal/api"
"github.com/centrifugal/centrifugo/v6/internal/build"
"github.com/centrifugal/centrifugo/v6/internal/client"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/confighelpers"
"github.com/centrifugal/centrifugo/v6/internal/consuming"
"github.com/centrifugal/centrifugo/v6/internal/jwtverify"
"github.com/centrifugal/centrifugo/v6/internal/logging"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/notify"
"github.com/centrifugal/centrifugo/v6/internal/service"
"github.com/centrifugal/centrifugo/v6/internal/survey"
"github.com/centrifugal/centrifugo/v6/internal/telemetry"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifugo/v6/internal/usage"
"github.com/centrifugal/centrifuge"
"github.com/joho/godotenv"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"go.uber.org/automaxprocs/maxprocs"
"google.golang.org/grpc"
_ "google.golang.org/grpc/encoding/gzip"
)
const edition = "oss"
func Run(cmd *cobra.Command, configFile string) {
dotEnvUsed := false
if tools.FileExists(".env") {
err := godotenv.Load()
if err != nil {
log.Fatal().Err(err).Msg("error loading .env file")
}
dotEnvUsed = true
}
cfg, cfgMeta, err := config.GetConfig(cmd, configFile)
if err != nil {
log.Fatal().Err(err).Msg("error getting config")
}
ctx, serviceCancel := context.WithCancel(context.Background())
defer serviceCancel()
// Ingest services (async consumers) have a separate lifecycle from the rest of
// services – see the shutdown sequence in handleSignals.
ingestCtx, ingestCancel := context.WithCancel(context.Background())
defer ingestCancel()
centrifugeLogHandler, logCloseFn := logging.Setup(cfg)
if logCloseFn != nil {
defer logCloseFn()
}
if cfgMeta.FileNotFound {
log.Warn().Msg("config file not found, continue using environment and flag options")
} else {
absConfPath, _ := filepath.Abs(configFile)
log.Info().Str("path", absConfPath).Msg("using config file")
if dotEnvUsed {
log.Info().Msg("environment variables have been loaded from .env file")
}
}
err = tools.WritePidFile(cfg.PidFile)
if err != nil {
log.Fatal().Err(err).Msg("error writing PID")
}
_, _ = maxprocs.Set(maxprocs.Logger(func(s string, i ...interface{}) {
log.Info().Msgf(strings.ToLower(s), i...)
}))
// Registered services will be run after node.Run() but before HTTP/GRPC servers start.
// Registered services will be stopped after node's shutdown and HTTP/GRPC servers shutdown.
serviceManager := service.NewManager()
entry := log.Info().
Str("version", build.Version).
Str("runtime", runtime.Version()).
Int("pid", os.Getpid()).
Int("gomaxprocs", runtime.GOMAXPROCS(0))
if fips140.Enabled() { // See https://go.dev/doc/security/fips140.
entry = entry.Bool("fips", true)
}
if cfg.Broker.Enabled {
entry = entry.Str("broker", cfg.Broker.Type)
}
if cfg.PresenceManager.Enabled {
entry = entry.Str("presence_manager", cfg.PresenceManager.Type)
}
if !cfg.Broker.Enabled || !cfg.PresenceManager.Enabled {
entry = entry.Str("engine", cfg.Engine.Type)
}
entry.Msg("starting Centrifugo")
if build.Version == "0.0.0" {
log.Warn().Msg("running a development build of Centrifugo (version 0.0.0), ensure to use release build in production")
}
err = cfg.Validate()
if err != nil {
log.Fatal().Err(err).Msg("error validating config")
}
cfgContainer, err := config.NewContainer(cfg)
if err != nil {
log.Fatal().Err(err).Msg("error creating config")
}
cfgContainer.ChannelOptionsCacheTTL = 200 * time.Millisecond
// Initialize centralized metrics registry.
err = metrics.Init(metrics.Config{
Namespace: "", // Use default "centrifugo" namespace.
ConstLabels: nil, // Can be populated from config in the future.
Registerer: nil, // Use prometheus.DefaultRegisterer.
NativeHistograms: cfg.Prometheus.NativeHistograms,
})
if err != nil {
log.Fatal().Err(err).Msg("error initializing metrics")
}
proxyMap, keepHeadersInContext, err := buildProxyMap(cfg)
if err != nil {
log.Fatal().Err(err).Msg("error building proxy map")
}
nodeCfg := centrifugeNodeConfig(build.Version, edition, cfgContainer, centrifugeLogHandler)
node, err := centrifuge.New(nodeCfg)
if err != nil {
log.Fatal().Err(err).Msg("error creating Centrifuge Node")
}
if cfg.OpenTelemetry.Enabled {
_, err := telemetry.SetupTracing(context.Background(), cfg.OpenTelemetry, node.ID())
if err != nil {
log.Fatal().Err(err).Msg("error setting up opentelemetry tracing")
}
}
err = configureEngines(node, cfgContainer)
if err != nil {
log.Fatal().Err(err).Msg("configure engines error")
}
err = configureMapBroker(node, cfgContainer)
if err != nil {
log.Fatal().Err(err).Msg("configure map broker error")
}
verifierConfig, err := confighelpers.MakeVerifierConfig(cfg.Client.Token)
if err != nil {
log.Fatal().Err(err).Msg("error creating JWT verifier config")
}
tokenVerifier, err := jwtverify.NewTokenVerifierJWT(verifierConfig, cfgContainer)
if err != nil {
log.Fatal().Err(err).Msg("error creating token verifier")
}
var subTokenVerifier *jwtverify.VerifierJWT
if cfg.Client.SubscriptionToken.Enabled {
log.Info().Msg("initializing separate verifier for subscription tokens")
subVerifier, err := confighelpers.MakeVerifierConfig(cfg.Client.SubscriptionToken.Token)
if err != nil {
log.Fatal().Err(err).Msg("error creating subscription JWT verifier config")
}
subTokenVerifier, err = jwtverify.NewTokenVerifierJWT(subVerifier, cfgContainer)
if err != nil {
log.Fatal().Err(err).Msg("error creating token verifier")
}
}
clientHandler := client.NewHandler(node, cfgContainer, tokenVerifier, subTokenVerifier, proxyMap)
err = clientHandler.Setup()
if err != nil {
log.Fatal().Err(err).Msg("error setting up client handler")
}
if cfg.RPC.Ping.Enabled {
log.Info().Str("method", cfg.RPC.Ping.Method).Msg("RPC ping extension enabled")
clientHandler.SetRPCExtension(cfg.RPC.Ping.Method, func(c client.Client, e centrifuge.RPCEvent) (centrifuge.RPCReply, error) {
return centrifuge.RPCReply{}, nil
})
}
surveyCaller := survey.NewCaller(node)
useAPIOpentelemetry := cfg.OpenTelemetry.Enabled && cfg.OpenTelemetry.API
useConsumingOpentelemetry := cfg.OpenTelemetry.Enabled && cfg.OpenTelemetry.Consuming
httpAPIExecutor := api.NewExecutor(node, cfgContainer, surveyCaller, api.ExecutorConfig{
Protocol: "http",
UseOpenTelemetry: useAPIOpentelemetry,
})
grpcAPIExecutor := api.NewExecutor(node, cfgContainer, surveyCaller, api.ExecutorConfig{
Protocol: "grpc",
UseOpenTelemetry: useAPIOpentelemetry,
})
consumingAPIExecutor := api.NewExecutor(node, cfgContainer, surveyCaller, api.ExecutorConfig{
Protocol: "consuming",
UseOpenTelemetry: useConsumingOpentelemetry,
})
consumingHandler := api.NewConsumingHandler(node, consumingAPIExecutor, api.ConsumingHandlerConfig{
UseOpenTelemetry: useConsumingOpentelemetry,
})
consumingServices, err := consuming.New(node.ID(), consumingHandler, cfg.Consumers)
if err != nil {
log.Fatal().Err(err).Msg("error initializing consumers")
}
// Consumers are registered in a separate manager: unlike other services they
// must be stopped before Node shutdown, so that no consumed message is
// acknowledged after this node lost the ability to deliver it.
ingestManager := service.NewManager()
ingestManager.Register(consumingServices...)
if cfg.Graphite.Enabled {
serviceManager.Register(graphiteExporter(cfg, nodeCfg))
}
var statsSender *usage.Sender
if !cfg.UsageStats.Disabled {
statsSender = usage.NewSender(node, cfgContainer, usage.Features{
Edition: edition,
Version: build.Version,
EngineEnabled: !cfg.Broker.Enabled || !cfg.PresenceManager.Enabled,
EngineType: cfg.Engine.Type,
BrokerEnabled: cfg.Broker.Enabled,
BrokerType: cfg.Broker.Type,
PresenceManagerEnabled: cfg.PresenceManager.Enabled,
PresenceManagerType: cfg.PresenceManager.Type,
Websocket: !cfg.WebSocket.Disabled,
HTTPStream: cfg.HTTPStream.Enabled,
SSE: cfg.SSE.Enabled,
UniWebsocket: cfg.UniWS.Enabled,
UniHTTPStream: cfg.UniHTTPStream.Enabled,
UniSSE: cfg.UniSSE.Enabled,
UniGRPC: cfg.UniGRPC.Enabled,
WebTransport: cfg.WebTransport.Enabled,
EnabledConsumers: usage.GetEnabledConsumers(cfg.Consumers),
GrpcAPI: cfg.GrpcAPI.Enabled,
SubscribeToPersonal: cfg.Client.SubscribeToUserPersonalChannel.Enabled,
Admin: cfg.Admin.Enabled,
ConnectProxy: proxyMap.ConnectProxy != nil,
RefreshProxy: proxyMap.RefreshProxy != nil,
SubscribeProxy: len(proxyMap.SubscribeProxies) > 0,
PublishProxy: len(proxyMap.PublishProxies) > 0,
RPCProxy: len(proxyMap.RpcProxies) > 0,
SubRefreshProxy: len(proxyMap.SubRefreshProxies) > 0,
SubscribeStreamProxy: len(proxyMap.SubscribeStreamProxies) > 0,
Swagger: cfg.Swagger.Enabled,
})
serviceManager.Register(statsSender)
}
notify.RegisterHandlers(node, statsSender)
if err = node.Run(); err != nil {
log.Fatal().Err(err).Msg("error running node")
}
serviceDone := runServices(ctx, serviceManager)
ingestDone := runServices(ingestCtx, ingestManager)
var grpcAPIServer *grpc.Server
if cfg.GrpcAPI.Enabled {
var err error
grpcAPIServer, err = runGRPCAPIServer(cfg, node, useAPIOpentelemetry, grpcAPIExecutor)
if err != nil {
log.Fatal().Err(err).Msg("error creating GRPC API server")
}
}
var grpcUniServer *grpc.Server
if cfg.UniGRPC.Enabled {
var err error
grpcUniServer, err = runGRPCUniServer(cfg, node)
if err != nil {
log.Fatal().Err(err).Msg("error creating uni GRPC server")
}
}
httpServers, err := runHTTPServers(node, cfgContainer, httpAPIExecutor, keepHeadersInContext)
if err != nil {
log.Fatal().Err(err).Msg("error running HTTP server")
}
logStartWarnings(cfg, cfgMeta)
handleSignals(
cmd, configFile, node, cfgContainer, tokenVerifier, subTokenVerifier,
httpServers, grpcAPIServer, grpcUniServer,
serviceDone, serviceCancel, ingestDone, ingestCancel,
)
}
// runServices runs all services registered in the manager and returns a channel
// closed when all of them stopped.
func runServices(ctx context.Context, manager *service.Manager) chan struct{} {
done := make(chan struct{})
manager.Run(ctx)
go func() {
defer close(done)
err := manager.Wait()
if err != nil && !errors.Is(err, context.Canceled) {
log.Error().Err(err).Msg("error running service")
os.Exit(1)
}
}()
return done
}
func handleSignals(
cmd *cobra.Command, configFile string, n *centrifuge.Node, cfgContainer *config.Container,
tokenVerifier *jwtverify.VerifierJWT, subTokenVerifier *jwtverify.VerifierJWT, httpServers []*http.Server,
grpcAPIServer *grpc.Server, grpcUniServer *grpc.Server, serviceDone chan struct{},
serviceCancel context.CancelFunc, ingestDone chan struct{}, ingestCancel context.CancelFunc,
) {
cfg := cfgContainer.Config()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, os.Interrupt, syscall.SIGTERM)
for {
sig := <-sigCh
log.Info().Msgf("signal received: %v", sig)
switch sig {
case syscall.SIGHUP:
// Reload application configuration on SIGHUP.
// Note that Centrifugo can't reload config for everything – just best effort to reload what's possible.
// We can now reload channel options and token verifiers.
log.Info().Msg("reloading configuration")
newCfg, _, err := config.GetConfig(cmd, configFile)
if err != nil {
log.Err(err).Msg("error reading config")
continue
}
if err = newCfg.Validate(); err != nil {
log.Error().Msgf("error validating config: %v", err)
continue
}
verifierConfig, err := confighelpers.MakeVerifierConfig(newCfg.Client.Token)
if err != nil {
log.Error().Msgf("error reloading: %v", err)
continue
}
if err = tokenVerifier.Reload(verifierConfig); err != nil {
log.Error().Msgf("error reloading: %v", err)
continue
}
if subTokenVerifier != nil {
subVerifierConfig, err := confighelpers.MakeVerifierConfig(newCfg.Client.SubscriptionToken.Token)
if err != nil {
log.Error().Msgf("error reloading: %v", err)
continue
}
if err := subTokenVerifier.Reload(subVerifierConfig); err != nil {
log.Error().Msgf("error reloading: %v", err)
continue
}
}
if err = cfgContainer.Reload(newCfg); err != nil {
log.Error().Msgf("error reloading: %v", err)
continue
}
log.Info().Msg("configuration successfully reloaded")
case syscall.SIGINT, os.Interrupt, syscall.SIGTERM:
log.Info().Msg("shutting down ...")
pidFile := cfg.PidFile
shutdownTimeout := cfg.Shutdown.Timeout
go time.AfterFunc(shutdownTimeout.ToDuration(), func() {
if pidFile != "" {
_ = os.Remove(pidFile)
}
log.Fatal().Msg("shutdown timeout reached")
})
var wg sync.WaitGroup
if grpcAPIServer != nil {
wg.Add(1)
go func() {
defer wg.Done()
grpcAPIServer.GracefulStop()
}()
}
if grpcUniServer != nil {
wg.Add(1)
go func() {
defer wg.Done()
grpcUniServer.GracefulStop()
}()
}
for _, srv := range httpServers {
wg.Add(1)
go func(srv *http.Server) {
defer wg.Done()
_ = srv.Shutdown(context.Background()) // We have a separate timeout goroutine.
}(srv)
}
// Stop consuming messages before Node shutdown and wait until consumers
// stopped. Otherwise a message consumed here may be acknowledged while
// this node is already unable to deliver it – such message must be left
// unacknowledged instead, to be redelivered upon restart. Note that we
// don't wait for HTTP/GRPC servers here: their shutdown only completes
// after Node disconnected clients, since long-lived streaming requests
// (SSE, HTTP-streaming, unidirectional GRPC) are in-flight until then.
ingestCancel()
<-ingestDone
_ = n.Shutdown(context.Background()) // We have a separate timeout goroutine.
wg.Wait()
serviceCancel()
<-serviceDone
if pidFile != "" {
_ = os.Remove(pidFile)
}
os.Exit(0)
}
}
}

81
internal/app/tls.go Normal file
View File

@@ -0,0 +1,81 @@
package app
import (
"crypto/tls"
stdlog "log"
"net/http"
"sync"
"time"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)
var startHTTPChallengeServerOnce sync.Once
func GetTLSConfig(cfg config.Config) (*tls.Config, error) {
tlsEnabled := cfg.HTTP.TLS.Enabled
tlsAutocertEnabled := cfg.HTTP.TLSAutocert.Enabled
tlsAutocertHostWhitelist := cfg.HTTP.TLSAutocert.HostWhitelist
tlsAutocertCacheDir := cfg.HTTP.TLSAutocert.CacheDir
tlsAutocertEmail := cfg.HTTP.TLSAutocert.Email
tlsAutocertServerName := cfg.HTTP.TLSAutocert.ServerName
tlsAutocertHTTP := cfg.HTTP.TLSAutocert.HTTP
tlsAutocertHTTPAddr := cfg.HTTP.TLSAutocert.HTTPAddr
if tlsAutocertEnabled {
certManager := autocert.Manager{
Prompt: autocert.AcceptTOS,
Email: tlsAutocertEmail,
}
if tlsAutocertHostWhitelist != nil {
certManager.HostPolicy = autocert.HostWhitelist(tlsAutocertHostWhitelist...)
}
if tlsAutocertCacheDir != "" {
certManager.Cache = autocert.DirCache(tlsAutocertCacheDir)
}
if tlsAutocertHTTP {
startHTTPChallengeServerOnce.Do(func() {
// getTLSConfig can be called several times.
acmeHTTPServer := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: tlsAutocertHTTPAddr,
ErrorLog: stdlog.New(&httpErrorLogWriter{log.Logger}, "", 0),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() {
log.Info().Msgf("serving ACME http_01 challenge on %s", tlsAutocertHTTPAddr)
if err := acmeHTTPServer.ListenAndServe(); err != nil {
log.Fatal().Err(err).Msgf("can't create server on %s to serve acme http challenge", tlsAutocertHTTPAddr)
}
}()
})
}
return &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
// See https://github.com/centrifugal/centrifugo/issues/144#issuecomment-279393819
if tlsAutocertServerName != "" && hello.ServerName == "" {
hello.ServerName = tlsAutocertServerName
}
return certManager.GetCertificate(hello)
},
NextProtos: []string{
"h2", "http/1.1", acme.ALPNProto,
},
}, nil
} else if tlsEnabled {
// Autocert disabled - just try to use provided SSL cert and key files.
return cfg.HTTP.TLS.ToGoTLSConfig("http_server")
}
return nil, nil
}

10
internal/build/build.go Normal file
View File

@@ -0,0 +1,10 @@
package build
// Version of server. Set to tag in CI during release.
var Version = "0.0.0"
// UsageStatsEndpoint is where we send stats. Set in CI during release.
var UsageStatsEndpoint string
// UsageStatsToken used for usage stats request auth. Set in CI during release.
var UsageStatsToken string

View File

@@ -0,0 +1,48 @@
package cli
import (
"fmt"
"os"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/spf13/cobra"
)
func CheckConfig() *cobra.Command {
var checkConfigFile string
var checkConfigStrict bool
var checkConfigCmd = &cobra.Command{
Use: "checkconfig",
Short: "Check configuration file",
Long: `Check Centrifugo configuration file`,
Run: func(cmd *cobra.Command, args []string) {
checkConfig(cmd, checkConfigFile, checkConfigStrict)
},
}
checkConfigCmd.Flags().StringVarP(&checkConfigFile, "config", "c", "config.json", "path to config file to check")
checkConfigCmd.Flags().BoolVarP(&checkConfigStrict, "strict", "s", false, "strict check - fail on unknown fields")
return checkConfigCmd
}
func checkConfig(cmd *cobra.Command, checkConfigFile string, strict bool) {
cfg, cfgMeta, err := config.GetConfig(cmd, checkConfigFile)
if err != nil {
fmt.Printf("error getting config: %v\n", err)
os.Exit(1)
}
if cfgMeta.FileNotFound {
fmt.Println("config file not found")
os.Exit(1)
}
err = cfg.Validate()
if err != nil {
fmt.Printf("error validating config: %s\n", err)
os.Exit(1)
}
if strict && len(cfgMeta.UnknownKeys) > 0 {
fmt.Printf("unknown keys in config: %v\n", strings.Join(cfgMeta.UnknownKeys, ", "))
os.Exit(1)
}
}

View File

@@ -0,0 +1,95 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/confighelpers"
"github.com/centrifugal/centrifugo/v6/internal/jwtverify"
"github.com/cristalhq/jwt/v5"
"github.com/spf13/cobra"
)
func CheckSubToken() *cobra.Command {
var checkSubTokenConfigFile string
var checkSubTokenCmd = &cobra.Command{
Use: "checksubtoken [TOKEN]",
Short: "Check subscription JWT",
Long: `Check subscription JWT`,
Run: func(cmd *cobra.Command, args []string) {
checkSubToken(cmd, checkSubTokenConfigFile, args)
},
}
checkSubTokenCmd.Flags().StringVarP(&checkSubTokenConfigFile, "config", "c", "config.json", "path to config file")
return checkSubTokenCmd
}
func checkSubToken(cmd *cobra.Command, checkSubTokenConfigFile string, args []string) {
cfg, _, err := config.GetConfig(cmd, checkSubTokenConfigFile)
if err != nil {
fmt.Printf("error getting config: %v\n", err)
os.Exit(1)
}
verifierConfig, err := confighelpers.MakeVerifierConfig(cfg.Client.Token)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if cfg.Client.SubscriptionToken.Enabled {
verifierConfig, err = confighelpers.MakeVerifierConfig(cfg.Client.SubscriptionToken.Token)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
}
if len(args) != 1 {
fmt.Printf("error: provide token to check [centrifugo checksubtoken <TOKEN>]\n")
os.Exit(1)
}
subject, channel, claims, err := parseAndVerifySubToken(verifierConfig, cfg, args[0])
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
var user = fmt.Sprintf("user \"%s\"", subject)
if subject == "" {
user = "anonymous user"
}
fmt.Printf("valid subscription token for %s and channel \"%s\"\npayload: %s\n", user, channel, string(claims))
}
// parseAndVerifySubToken checks subscription JWT for user.
func parseAndVerifySubToken(config jwtverify.VerifierConfig, cfg config.Config, t string) (string, string, []byte, error) {
token, err := jwt.ParseNoVerify([]byte(t)) // Will be verified later.
if err != nil {
return "", "", nil, err
}
var claims jwtverify.SubscribeTokenClaims
err = json.Unmarshal(token.Claims(), &claims)
if err != nil {
return "", "", nil, err
}
ct, err := verifySub(config, cfg, t)
if err != nil {
return "", "", nil, fmt.Errorf("token with algorithm %s and claims %s has error: %v", token.Header().Algorithm, string(token.Claims()), err)
}
return ct.UserID, ct.Channel, token.Claims(), nil
}
func verifySub(verifierConf jwtverify.VerifierConfig, cfg config.Config, token string) (jwtverify.SubscribeToken, error) {
cfgContainer, err := config.NewContainer(cfg)
if err != nil {
return jwtverify.SubscribeToken{}, err
}
verifier, err := jwtverify.NewTokenVerifierJWT(verifierConf, cfgContainer)
if err != nil {
return jwtverify.SubscribeToken{}, err
}
return verifier.VerifySubscribeToken(token, false)
}

View File

@@ -0,0 +1,88 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/confighelpers"
"github.com/centrifugal/centrifugo/v6/internal/jwtverify"
"github.com/cristalhq/jwt/v5"
"github.com/spf13/cobra"
)
func CheckToken() *cobra.Command {
var checkTokenConfigFile string
var checkTokenCmd = &cobra.Command{
Use: "checktoken [TOKEN]",
Short: "Check connection JWT",
Long: `Check connection JWT`,
Run: func(cmd *cobra.Command, args []string) {
checkToken(cmd, checkTokenConfigFile, args)
},
}
checkTokenCmd.Flags().StringVarP(&checkTokenConfigFile, "config", "c", "config.json", "path to config file")
return checkTokenCmd
}
func checkToken(cmd *cobra.Command, checkTokenConfigFile string, args []string) {
cfg, _, err := config.GetConfig(cmd, checkTokenConfigFile)
if err != nil {
fmt.Printf("error getting config: %v\n", err)
os.Exit(1)
}
verifierConfig, err := confighelpers.MakeVerifierConfig(cfg.Client.Token)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if len(args) != 1 {
fmt.Printf("error: provide token to check [centrifugo checktoken <TOKEN>]\n")
os.Exit(1)
}
subject, claims, err := parseAndVerifyToken(verifierConfig, cfg, args[0])
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
var user = fmt.Sprintf("user %s", subject)
if subject == "" {
user = "anonymous user"
}
fmt.Printf("valid token for %s\npayload: %s\n", user, string(claims))
}
// parseAndVerifyToken checks JWT for user.
func parseAndVerifyToken(config jwtverify.VerifierConfig, ruleConfig config.Config, t string) (string, []byte, error) {
token, err := jwt.ParseNoVerify([]byte(t)) // Will be verified later.
if err != nil {
return "", nil, err
}
claims := &jwt.RegisteredClaims{}
err = json.Unmarshal(token.Claims(), claims)
if err != nil {
return "", nil, err
}
ct, err := verify(config, ruleConfig, t)
if err != nil {
return "", nil, fmt.Errorf("token with algorithm %s and claims %s has error: %v", token.Header().Algorithm, string(token.Claims()), err)
}
return ct.UserID, token.Claims(), nil
}
func verify(verifierConf jwtverify.VerifierConfig, cfg config.Config, token string) (jwtverify.ConnectToken, error) {
cfgContainer, err := config.NewContainer(cfg)
if err != nil {
return jwtverify.ConnectToken{}, err
}
verifier, err := jwtverify.NewTokenVerifierJWT(verifierConf, cfgContainer)
if err != nil {
return jwtverify.ConnectToken{}, err
}
return verifier.VerifyConnectToken(token, false)
}

352
internal/cli/configdoc.go Normal file
View File

@@ -0,0 +1,352 @@
package cli
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"os"
"reflect"
"regexp"
"strconv"
"strings"
_ "embed"
"github.com/centrifugal/centrifugo/v6/internal/build"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/pkg/browser"
"github.com/spf13/cobra"
"github.com/yuin/goldmark"
)
//go:embed configdoc/template.html
var htmlTemplate string
func ConfigDoc() *cobra.Command {
var mdOutput bool
var section string
var port int
var baseLevel int
var configDocCmd = &cobra.Command{
Use: "configdoc",
Short: "Show Centrifugo configuration documentation generated from source code",
Long: `Show Centrifugo configuration documentation generated from source code`,
Run: func(cmd *cobra.Command, args []string) {
configDoc(port, mdOutput, section, baseLevel)
},
}
configDocCmd.Flags().BoolVarP(&mdOutput, "markdown", "m", false, "output markdown to stdout")
configDocCmd.Flags().StringVarP(&section, "section", "s", "", "filter by top-level section name")
configDocCmd.Flags().IntVarP(&port, "port", "p", 6060, "port to run server on")
configDocCmd.Flags().IntVarP(&baseLevel, "base-level", "l", 0, "base level to use")
return configDocCmd
}
func configDoc(port int, mdOutput bool, section string, baseLevel int) {
// Documentation is built from the config structs at runtime — descriptions
// come from the `doc` struct tags, types and defaults from reflection.
docs := documentStruct(config.Config{}, "", 1)
if mdOutput {
mdContent := convertDocsToMarkdown(docs, section, baseLevel, mdOutput)
fmt.Println(mdContent)
return
}
// Filter docs by section if specified
filteredDocs := docs
if section != "" {
filteredDocs = make([]FieldDoc, 0)
for _, doc := range docs {
if strings.HasPrefix(doc.Field, section) {
filteredDocs = append(filteredDocs, doc)
}
}
}
go func() {
url := "http://localhost:" + strconv.Itoa(port)
// OpenURL will open the default browser.
if err := browser.OpenURL(url); err != nil {
fmt.Printf("Failed to open browser automatically: %v, see %s\n", err, url)
}
}()
http.HandleFunc("/", makeMarkdownHandler(filteredDocs))
if err := http.ListenAndServe(":"+strconv.Itoa(port), nil); err != nil && !errors.Is(err, http.ErrServerClosed) { //nolint:gosec // Only for development use.
fmt.Println("Failed to start server:", err)
os.Exit(1)
}
}
// FieldDoc represents the JSON documentation for a configuration field.
type FieldDoc struct {
Field string `json:"field"`
Name string `json:"name"`
GoName string `json:"go_name"`
Level int `json:"level"`
Type string `json:"type"`
Default string `json:"default"`
Comment string `json:"comment"`
IsComplexType bool `json:"is_complex_type"`
Children []FieldDoc `json:"children,omitempty"`
}
// ConvertDocsToMarkdown recursively converts a slice of FieldDoc entries into Markdown.
func convertDocsToMarkdown(docs []FieldDoc, section string, baseLevel int, mdOutput bool) string {
var sb strings.Builder
for _, doc := range docs {
if section != "" && !strings.HasPrefix(doc.Field, section) {
continue
}
// Generate a header with the appropriate Markdown level.
fieldLevel := doc.Level + baseLevel
if fieldLevel > 6 {
fieldLevel = 6
}
header := strings.Repeat("#", fieldLevel)
typeDesc := fmt.Sprintf("Type: `%s`", doc.Type)
if doc.IsComplexType {
typeDesc = fmt.Sprintf("Type: `%s` object", doc.Type)
}
if doc.Default != "" {
typeDesc += fmt.Sprintf(". Default: `%s`", doc.Default)
}
var envVar string
if !strings.Contains(doc.Field, "[]") {
if !doc.IsComplexType {
envVar = "CENTRIFUGO_" + strings.ToUpper(strings.ReplaceAll(doc.Field, `.`, "_"))
} else {
if strings.Contains(doc.Field, "namespaces") || strings.Contains(doc.Field, "proxies") || strings.Contains(doc.Field, "consumers") {
envVar = "CENTRIFUGO_" + strings.ToUpper(strings.ReplaceAll(doc.Field, `.`, "_"))
} else if doc.Default == "[]" {
envVar = "CENTRIFUGO_" + strings.ToUpper(strings.ReplaceAll(doc.Field, `.`, "_"))
}
}
} else {
if !strings.HasSuffix(doc.Field, ".name") &&
(strings.HasPrefix(doc.Field, "namespaces") ||
strings.HasPrefix(doc.Field, "proxies") ||
strings.HasPrefix(doc.Field, "consumers")) &&
(!doc.IsComplexType || doc.Default == "[]") {
field := strings.Replace(doc.Field, `[]`, "_<NAME>", 1)
envVar = "CENTRIFUGO_" + strings.ToUpper(strings.ReplaceAll(field, `.`, "_"))
}
if strings.Contains(envVar, "[]") {
envVar = ""
}
}
if envVar != "" {
typeDesc += fmt.Sprintf("\n\nEnv: `%s`", envVar)
}
comment := doc.Comment
if comment == "" {
comment = "No documentation available."
}
if strings.HasPrefix(comment, doc.GoName) {
comment = fmt.Sprintf("`%s`%s", doc.Name, comment[len(doc.GoName):])
}
field := fmt.Sprintf("`%s`", doc.Field)
if !mdOutput && doc.IsComplexType {
if doc.Default == "[]" {
field += " `[...]`"
} else {
field += " `{...}`"
}
}
sb.WriteString(fmt.Sprintf("%s %s\n\n%s\n\n%s\n\n", header, field, typeDesc, comment))
if len(doc.Children) > 0 {
sb.WriteString(convertDocsToMarkdown(doc.Children, section, baseLevel, mdOutput))
}
}
return sb.String()
}
func makeMarkdownHandler(docs []FieldDoc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Check if simple markdown view is requested
if r.URL.Query().Get("simple") == "1" {
serveSimpleMarkdownView(w, docs)
return
}
// Convert docs to JSON for JavaScript consumption
configJSON, err := json.Marshal(docs)
if err != nil {
http.Error(w, "Error marshaling config data: "+err.Error(), http.StatusInternalServerError)
return
}
// Parse the HTML template
tmpl, err := template.New("config").Parse(htmlTemplate)
if err != nil {
http.Error(w, "Error parsing template: "+err.Error(), http.StatusInternalServerError)
return
}
// Prepare template data
data := struct {
Version string
Content template.HTML
ConfigJSON template.JS
}{
Version: build.Version,
Content: "", // Content is now generated by JavaScript.
ConfigJSON: template.JS(configJSON), //nolint:gosec // Trusted data.
}
// Execute template
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
http.Error(w, "Error executing template: "+err.Error(), http.StatusInternalServerError)
return
}
}
}
func serveSimpleMarkdownView(w http.ResponseWriter, docs []FieldDoc) {
mdContent := convertDocsToMarkdown(docs, "", 0, false)
var buf bytes.Buffer
if err := goldmark.Convert([]byte(mdContent), &buf); err != nil {
http.Error(w, "Error converting markdown: "+err.Error(), http.StatusInternalServerError)
return
}
simpleHTML := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Centrifugo %s Configuration Options (Simple View)</title>
<link href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.0/dist/materia/bootstrap.min.css" rel="stylesheet">
<style>
body { padding: 2rem; }
.markdown-body { max-width: 1024px; margin: auto; }
code { color: #139f87; background-color: #f8f9fa; padding: 0.2rem 0.4rem; border-radius: 0.25rem; }
.controls { text-align: center; margin-bottom: 2rem; }
</style>
</head>
<body>
<div class="container markdown-body">
<div class="controls">
<a href="/" class="btn btn-primary">Switch to Enhanced View</a>
</div>
%s
</div>
</body>
</html>`, build.Version, buf.String())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(simpleHTML))
}
var docCodeRe = regexp.MustCompile(`<<(.+?)>>`)
// convertDocCodes turns <<value>> markers in doc text into Markdown inline code.
func convertDocCodes(s string) string {
return docCodeRe.ReplaceAllString(s, "`$1`")
}
// getDisplayType returns the base type name and whether it's a complex (object)
// type. Both the type label and the complex/object classification are shared via
// configtypes so configdoc and the admin config UI (Pro) stay consistent.
func getDisplayType(t reflect.Type) (string, bool) {
return configtypes.TypeLabel(t), configtypes.IsComplexType(t)
}
// documentStruct walks a config struct via reflection and builds the field
// documentation tree at runtime — descriptions from the `doc` tag, types and
// defaults from reflection. Fields tagged `expose:"-"` are excluded.
func documentStruct(cfg interface{}, parentKey string, level int) []FieldDoc {
var docs []FieldDoc
t := reflect.TypeOf(cfg)
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return docs
}
fieldLevel := level
if parentKey != "" {
fieldLevel = level + 1
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.Tag.Get("json") == "-" {
continue
}
// Flatten squashed/embedded structs.
if configtypes.IsSquash(field) {
var nested interface{}
if field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Struct {
nested = reflect.New(field.Type.Elem()).Interface()
} else if field.Type.Kind() == reflect.Struct {
nested = reflect.New(field.Type).Interface()
}
if nested != nil {
docs = append(docs, documentStruct(nested, parentKey, level)...)
}
continue
}
if field.Tag.Get("expose") == "-" {
continue
}
keyTag := configtypes.JSONKey(field)
fullKey := keyTag
if parentKey != "" {
fullKey = parentKey + "." + keyTag
}
displayType, isComplex := getDisplayType(field.Type)
docEntry := FieldDoc{
Field: fullKey,
Name: keyTag,
GoName: field.Name,
Level: fieldLevel,
Type: displayType,
Default: field.Tag.Get("default"),
IsComplexType: isComplex,
Comment: convertDocCodes(field.Tag.Get("doc")),
}
var children []FieldDoc
switch field.Type.Kind() {
case reflect.Struct:
if field.Type.PkgPath() != "time" || field.Type.Name() != "Time" {
children = documentStruct(reflect.New(field.Type).Interface(), fullKey, fieldLevel)
}
case reflect.Pointer:
if field.Type.Elem().Kind() == reflect.Struct {
children = documentStruct(reflect.New(field.Type.Elem()).Interface(), fullKey, fieldLevel)
}
case reflect.Slice:
elem := field.Type.Elem()
if elem.Kind() == reflect.Pointer {
elem = elem.Elem()
}
if elem.Kind() == reflect.Struct {
children = documentStruct(reflect.New(elem).Interface(), fullKey+"[]", fieldLevel)
}
}
if len(children) > 0 {
docEntry.Children = children
}
docs = append(docs, docEntry)
}
return docs
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
package cli
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
func DefaultConfig() *cobra.Command {
var defaultConfigFile string
var baseConfigFile string
var dryRun bool
var defaultConfigCmd = &cobra.Command{
Use: "defaultconfig",
Short: "Generate full configuration file with defaults",
Long: `Generate full Centrifugo configuration file with defaults`,
Run: func(cmd *cobra.Command, args []string) {
defaultConfig(defaultConfigFile, baseConfigFile, dryRun)
},
}
defaultConfigCmd.Flags().StringVarP(&defaultConfigFile, "config", "c", "config.json", "path to default config file to generate")
defaultConfigCmd.Flags().StringVarP(&baseConfigFile, "base", "b", "", "path to the base config file to use")
defaultConfigCmd.Flags().BoolVarP(&dryRun, "dry-run", "d", false, "dry run mode (do not write file and just print generated config to stdout)")
return defaultConfigCmd
}
func defaultConfig(configFile string, baseFile string, dryRun bool) {
if !dryRun {
exists, err := tools.PathExists(configFile)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if exists {
fmt.Printf("error: target file already exists\n")
os.Exit(1)
}
}
conf, _, err := config.GetConfig(nil, baseFile)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if err = conf.Validate(); err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
var b []byte
ext := filepath.Ext(configFile)
if len(ext) > 1 {
ext = ext[1:]
}
supportedExtensions := []string{"json", "toml", "yaml", "yml"}
// This is an unreleased feature so we remove RedisNats from generated config.
conf.Broker.RedisNats = nil
jsonBytes, err := json.MarshalIndent(conf, "", " ")
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
switch ext {
case "json":
b = jsonBytes
case "toml":
b, err = toml.Marshal(conf)
case "yaml", "yml":
b, err = yaml.Marshal(conf)
default:
err = errors.New("output config file must have one of supported extensions: " + strings.Join(supportedExtensions, ", "))
}
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if dryRun {
fmt.Println(string(b))
return
}
err = os.WriteFile(configFile, b, 0600)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
}

140
internal/cli/defaultenv.go Normal file
View File

@@ -0,0 +1,140 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"reflect"
"slices"
"sort"
"strconv"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/config/envconfig"
"github.com/spf13/cobra"
)
func DefaultEnv() *cobra.Command {
var baseConfigFile string
var baseNonZeroOnly bool
var defaultEnvCmd = &cobra.Command{
Use: "defaultenv",
Short: "Generate full environment var list with defaults",
Long: `Generate full Centrifugo environment var list with defaults`,
Run: func(cmd *cobra.Command, args []string) {
defaultEnv(baseConfigFile, baseNonZeroOnly)
},
}
defaultEnvCmd.Flags().StringVarP(&baseConfigFile, "base", "b", "", "path to the base config file to use")
defaultEnvCmd.Flags().BoolVarP(&baseNonZeroOnly, "base-non-zero-only", "", false, "only output environment variables for values which were non zero in base config file")
return defaultEnvCmd
}
func defaultEnv(baseFile string, baseNonZeroOnly bool) {
conf, meta, err := config.GetConfig(nil, baseFile)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if err = conf.Validate(); err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
printSortedEnvVars(meta.KnownEnvVars, baseNonZeroOnly)
}
var namedArrayEnvVars = []string{
"CENTRIFUGO_CHANNEL_NAMESPACES",
"CENTRIFUGO_RPC_NAMESPACES",
"CENTRIFUGO_CONSUMERS",
"CENTRIFUGO_PROXIES",
}
func printSortedEnvVars(knownEnvVars map[string]envconfig.VarInfo, baseNonZeroOnly bool) {
var envKeys []string
for env := range knownEnvVars {
envKeys = append(envKeys, env)
}
sort.Strings(envKeys)
for _, env := range envKeys {
if strings.HasSuffix(env, "-") {
// Hacky way to skip unnecessary struct field, can be based on struct tag.
continue
}
varInfo := knownEnvVars[env]
if baseNonZeroOnly && !varInfo.NonZeroInFile {
continue
}
value := valueToStringReflect(knownEnvVars[env].Field)
if slices.Contains(namedArrayEnvVars, env) { // Special handling for named array env vars.
var items []struct {
Name string `json:"name"`
}
s, err := strconv.Unquote(value)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
err = json.Unmarshal([]byte(s), &items)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
names := make([]string, len(items))
for i, item := range items {
names[i] = item.Name
}
value = strconv.Quote(strings.Join(names, " "))
}
fmt.Printf("%s=%s\n", env, value)
}
}
// valueToStringReflect converts a reflect.Value to a string in a way suitable for environment variables.
func valueToStringReflect(v reflect.Value) string {
switch v.Kind() {
case reflect.Slice, reflect.Array:
// Check if the element type of the slice/array is a struct
if v.Type().Elem().Kind() == reflect.Struct {
// Marshal the entire array/slice of structs to JSON
jsonValue, err := json.Marshal(v.Interface())
if err != nil {
panic(err)
}
// Escape double quotes to make the value suitable for environment variables
return fmt.Sprintf("\"%v\"", strings.ReplaceAll(string(jsonValue), `"`, `\"`))
}
var elements []string
for i := 0; i < v.Len(); i++ {
elements = append(elements, valueToStringReflect(v.Index(i)))
}
if len(elements) == 0 {
return "\"\""
}
return fmt.Sprintf("%v", strings.Join(elements, " "))
case reflect.Struct:
// You can customize how structs should be serialized if needed
return fmt.Sprintf("%v", v.Interface()) // Fallback to default formatting (customize if necessary)
case reflect.Map:
jsonValue, err := json.Marshal(v.Interface())
if err != nil {
panic(err)
}
// Escape double quotes to make the value suitable for environment variables
return fmt.Sprintf("\"%v\"", strings.ReplaceAll(string(jsonValue), `"`, `\"`))
case reflect.Pointer:
if v.IsNil() {
return ""
}
return valueToStringReflect(v.Elem()) // Dereference the pointer and recursively process
case reflect.Invalid:
return "" // Handle zero/nil values
case reflect.String:
return fmt.Sprintf("\"%v\"", v.Interface())
default:
// Fallback for other types (int, bool, etc.)
return fmt.Sprintf("%v", v.Interface())
}
}

45
internal/cli/genconfig.go Normal file
View File

@@ -0,0 +1,45 @@
package cli
import (
"fmt"
"os"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/spf13/cobra"
)
func GenConfig() *cobra.Command {
var outputConfigFile string
var genConfigCmd = &cobra.Command{
Use: "genconfig",
Short: "Generate minimal configuration file to start with",
Long: `Generate minimal configuration file to start with`,
Run: func(cmd *cobra.Command, args []string) {
genConfig(cmd, outputConfigFile)
},
}
genConfigCmd.Flags().StringVarP(&outputConfigFile, "config", "c", "config.json", "path to output config file")
return genConfigCmd
}
func genConfig(cmd *cobra.Command, outputConfigFile string) {
err := tools.GenerateConfig(outputConfigFile)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
cfg, _, err := config.GetConfig(cmd, outputConfigFile)
if err != nil {
_ = os.Remove(outputConfigFile)
fmt.Printf("error getting config: %v\n", err)
os.Exit(1)
}
err = cfg.Validate()
if err != nil {
_ = os.Remove(outputConfigFile)
fmt.Printf("error validating config: %v\n", err)
os.Exit(1)
}
}

123
internal/cli/gensubtoken.go Normal file
View File

@@ -0,0 +1,123 @@
package cli
import (
"encoding/json"
"errors"
"fmt"
"os"
"time"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/confighelpers"
"github.com/centrifugal/centrifugo/v6/internal/jwtverify"
"github.com/cristalhq/jwt/v5"
"github.com/spf13/cobra"
"github.com/tidwall/sjson"
)
func GenSubToken() *cobra.Command {
var genSubTokenConfigFile string
var genSubTokenUser string
var genSubTokenChannel string
var genSubTokenTTL int64
var genSubTokenQuiet bool
var genSubTokenCmd = &cobra.Command{
Use: "gensubtoken",
Short: "Generate sample subscription JWT for user",
Long: `Generate sample subscription JWT for user`,
Run: func(cmd *cobra.Command, args []string) {
genSubToken(cmd, genSubTokenConfigFile, genSubTokenUser, genSubTokenChannel, genSubTokenTTL, genSubTokenQuiet)
},
}
genSubTokenCmd.Flags().StringVarP(&genSubTokenConfigFile, "config", "c", "config.json", "path to config file")
genSubTokenCmd.Flags().StringVarP(&genSubTokenUser, "user", "u", "", "user ID")
genSubTokenCmd.Flags().StringVarP(&genSubTokenChannel, "channel", "s", "", "channel")
genSubTokenCmd.Flags().Int64VarP(&genSubTokenTTL, "ttl", "t", 3600*24*7, "token TTL in seconds, use -1 for token without expiration")
genSubTokenCmd.Flags().BoolVarP(&genSubTokenQuiet, "quiet", "q", false, "only output the token without anything else")
return genSubTokenCmd
}
func genSubToken(
cmd *cobra.Command, genSubTokenConfigFile string, genSubTokenUser string,
genSubTokenChannel string, genSubTokenTTL int64, genSubTokenQuiet bool,
) {
cfg, _, err := config.GetConfig(cmd, genSubTokenConfigFile)
if err != nil {
fmt.Printf("error getting config: %v\n", err)
os.Exit(1)
}
if genSubTokenChannel == "" {
fmt.Println("channel is required")
os.Exit(1)
}
verifierConfig, err := confighelpers.MakeVerifierConfig(cfg.Client.Token)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if cfg.Client.SubscriptionToken.Enabled {
verifierConfig, err = confighelpers.MakeVerifierConfig(cfg.Client.SubscriptionToken.Token)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
}
token, err := generateSubToken(verifierConfig, genSubTokenUser, genSubTokenChannel, genSubTokenTTL)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
var user = fmt.Sprintf("user \"%s\"", genSubTokenUser)
if genSubTokenUser == "" {
user = "anonymous user"
}
exp := "without expiration"
if genSubTokenTTL >= 0 {
exp = fmt.Sprintf("with expiration TTL %s", time.Duration(genSubTokenTTL)*time.Second)
}
if genSubTokenQuiet {
fmt.Print(token)
return
}
fmt.Printf("HMAC SHA-256 JWT for %s and channel \"%s\" %s:\n%s\n", user, genSubTokenChannel, exp, token)
}
// generateSubToken generates sample subscription JWT for user.
func generateSubToken(config jwtverify.VerifierConfig, user string, channel string, ttlSeconds int64) (string, error) {
if config.HMACSecretKey == "" {
return "", errors.New("gensubtoken only supports generating HMAC-signed tokens, but no HMAC secret key is set in the configuration (client.subscription_token.token.hmac_secret_key, or client.token.hmac_secret_key when subscription token is not separately configured) – if you use RSA/ECDSA/JWKS token verification you need to generate the JWT with your own tooling")
}
signer, err := jwt.NewSignerHS(jwt.HS256, []byte(config.HMACSecretKey))
if err != nil {
return "", fmt.Errorf("error creating HMAC signer: %w", err)
}
builder := jwt.NewBuilder(signer)
claims := jwtverify.SubscribeTokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: user,
},
Channel: channel,
}
if ttlSeconds > 0 {
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Duration(ttlSeconds) * time.Second))
}
encodedClaims, err := json.Marshal(claims)
if err != nil {
return "", err
}
if config.UserIDClaim != "" {
encodedClaims, err = sjson.SetBytes(encodedClaims, config.UserIDClaim, user)
if err != nil {
return "", err
}
}
token, err := builder.Build(encodedClaims)
if err != nil {
return "", err
}
return token.String(), nil
}

106
internal/cli/gentoken.go Normal file
View File

@@ -0,0 +1,106 @@
package cli
import (
"encoding/json"
"errors"
"fmt"
"os"
"time"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/confighelpers"
"github.com/centrifugal/centrifugo/v6/internal/jwtverify"
"github.com/cristalhq/jwt/v5"
"github.com/spf13/cobra"
"github.com/tidwall/sjson"
)
func GenToken() *cobra.Command {
var genTokenConfigFile string
var genTokenUser string
var genTokenTTL int64
var genTokenQuiet bool
var genTokenCmd = &cobra.Command{
Use: "gentoken",
Short: "Generate sample connection JWT for user",
Long: `Generate sample connection JWT for user`,
Run: func(cmd *cobra.Command, args []string) {
genToken(cmd, genTokenConfigFile, genTokenUser, genTokenTTL, genTokenQuiet)
},
}
genTokenCmd.Flags().StringVarP(&genTokenConfigFile, "config", "c", "config.json", "path to config file")
genTokenCmd.Flags().StringVarP(&genTokenUser, "user", "u", "", "user ID, by default anonymous")
genTokenCmd.Flags().Int64VarP(&genTokenTTL, "ttl", "t", 3600*24*7, "token TTL in seconds, use -1 for token without expiration")
genTokenCmd.Flags().BoolVarP(&genTokenQuiet, "quiet", "q", false, "only output the token without anything else")
return genTokenCmd
}
func genToken(cmd *cobra.Command, genTokenConfigFile string, genTokenUser string, genTokenTTL int64, genTokenQuiet bool) {
cfg, _, err := config.GetConfig(cmd, genTokenConfigFile)
if err != nil {
fmt.Printf("error getting config: %v\n", err)
os.Exit(1)
}
verifierConfig, err := confighelpers.MakeVerifierConfig(cfg.Client.Token)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
token, err := generateToken(verifierConfig, genTokenUser, genTokenTTL)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
var user = fmt.Sprintf("user \"%s\"", genTokenUser)
if genTokenUser == "" {
user = "anonymous user"
}
exp := "without expiration"
if genTokenTTL >= 0 {
exp = fmt.Sprintf("with expiration TTL %s", time.Duration(genTokenTTL)*time.Second)
}
if genTokenQuiet {
fmt.Print(token)
return
}
fmt.Printf("HMAC SHA-256 JWT for %s %s:\n%s\n", user, exp, token)
}
// generateToken generates sample JWT for user.
func generateToken(config jwtverify.VerifierConfig, user string, ttlSeconds int64) (string, error) {
if config.HMACSecretKey == "" {
return "", errors.New("gentoken only supports generating HMAC-signed tokens, but no HMAC secret key is set in the configuration (client.token.hmac_secret_key) – if you use RSA/ECDSA/JWKS token verification you need to generate the JWT with your own tooling")
}
signer, err := jwt.NewSignerHS(jwt.HS256, []byte(config.HMACSecretKey))
if err != nil {
return "", fmt.Errorf("error creating HMAC signer: %w", err)
}
builder := jwt.NewBuilder(signer)
claims := jwtverify.ConnectTokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: user,
},
}
if ttlSeconds > 0 {
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Duration(ttlSeconds) * time.Second))
}
encodedClaims, err := json.Marshal(claims)
if err != nil {
return "", err
}
if config.UserIDClaim != "" {
encodedClaims, err = sjson.SetBytes(encodedClaims, config.UserIDClaim, user)
if err != nil {
return "", err
}
}
token, err := builder.Build(encodedClaims)
if err != nil {
return "", err
}
return token.String(), nil
}

38
internal/cli/serve.go Normal file
View File

@@ -0,0 +1,38 @@
package cli
import (
"fmt"
"net"
"net/http"
"os"
"strconv"
"github.com/spf13/cobra"
)
func Serve() *cobra.Command {
var serveDir string
var servePort int
var serveAddr string
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Run static file server (for development only)",
Long: `Run static file server (for development only)`,
Run: func(cmd *cobra.Command, args []string) {
serve(serveAddr, servePort, serveDir)
},
}
serveCmd.Flags().StringVarP(&serveDir, "dir", "d", "./", "path to directory")
serveCmd.Flags().IntVarP(&servePort, "port", "p", 3000, "port to serve on")
serveCmd.Flags().StringVarP(&serveAddr, "address", "a", "", "interface to serve on (default: all interfaces)")
return serveCmd
}
func serve(serveAddr string, servePort int, serveDir string) {
address := net.JoinHostPort(serveAddr, strconv.Itoa(servePort))
fmt.Printf("start serving %s on %s\n", serveDir, address)
if err := http.ListenAndServe(address, http.FileServer(http.Dir(serveDir))); err != nil { //nolint:gosec // Only for development use.
fmt.Println(err)
os.Exit(1)
}
}

25
internal/cli/version.go Normal file
View File

@@ -0,0 +1,25 @@
package cli
import (
"fmt"
"runtime"
"github.com/centrifugal/centrifugo/v6/internal/build"
"github.com/spf13/cobra"
)
func Version() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Centrifugo version information",
Long: `Print the version information of Centrifugo`,
Run: func(cmd *cobra.Command, args []string) {
version()
},
}
}
func version() {
fmt.Printf("Centrifugo v%s (Go version: %s)\n", build.Version, runtime.Version())
}

19
internal/client/client.go Normal file
View File

@@ -0,0 +1,19 @@
package client
import (
"context"
"github.com/centrifugal/centrifuge"
)
// Client represents client connection.
type Client interface {
ID() string
UserID() string
IsSubscribed(string) bool
Context() context.Context
Transport() centrifuge.TransportInfo
AcquireStorage() (map[string]any, func(map[string]any))
Unsubscribe(ch string, unsubscribe ...centrifuge.Unsubscribe)
WritePublication(channel string, publication *centrifuge.Publication, sp centrifuge.StreamPosition) error
}

1332
internal/client/handler.go Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
package client
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"hash"
"strconv"
"strings"
"sync"
)
const gracePeriodSeconds = 5
func parseSignatureTimestamps(sig string) (iat int64, expiry int64) {
first := strings.IndexByte(sig, ':')
if first < 0 {
return 0, 0
}
rest := sig[first+1:]
second := strings.IndexByte(rest, ':')
if second < 0 {
return 0, 0
}
iatVal, err := strconv.ParseInt(sig[:first], 10, 64)
if err != nil {
return 0, 0
}
expiryVal, err := strconv.ParseInt(rest[:second], 10, 64)
if err != nil {
return 0, 0
}
return iatVal, expiryVal
}
// trackSignatureVerifier reuses HMAC hashers and buffers for signature verification.
// Thread-safe via sync.Pool.
type trackSignatureVerifier struct {
macPool sync.Pool
bufPool sync.Pool
}
type verifyBufs struct {
payload []byte
hexBuf []byte
expectedHex []byte
}
func newTrackSignatureVerifier(secret string) *trackSignatureVerifier {
return &trackSignatureVerifier{
macPool: sync.Pool{
New: func() any {
return hmac.New(sha256.New, []byte(secret))
},
},
bufPool: sync.Pool{
New: func() any {
return &verifyBufs{
payload: make([]byte, 0, 256),
hexBuf: make([]byte, hex.EncodedLen(sha256.Size)),
expectedHex: make([]byte, hex.EncodedLen(sha256.Size)),
}
},
},
}
}
func verifyTrackSignature(secret string, channel string, sig string, keys []string, userID string) bool {
return newTrackSignatureVerifier(secret).verify(channel, sig, keys, userID)
}
func (v *trackSignatureVerifier) verify(channel string, sig string, keys []string, userID string) bool {
first := strings.IndexByte(sig, ':')
if first < 0 {
return false
}
rest := sig[first+1:]
second := strings.IndexByte(rest, ':')
if second < 0 {
return false
}
iatStr := sig[:first]
expiryStr := rest[:second]
hmacHex := rest[second+1:]
keysHash := sha256.Sum256([]byte(strings.Join(keys, "\x00")))
mac := v.macPool.Get().(hash.Hash)
bufs := v.bufPool.Get().(*verifyBufs)
// Payload fields are NUL-separated rather than ':'-separated so colons
// inside userID or channel (e.g. "news:tech", "alice:org") can't shift
// between fields and yield a colliding HMAC for a different (userID,
// channel) tuple. The outer signature string itself stays ':'-separated
// because its fields (iat / exp / hmac_hex) are colon-free by construction.
mac.Reset()
bufs.payload = bufs.payload[:0]
bufs.payload = append(bufs.payload, iatStr...)
bufs.payload = append(bufs.payload, 0)
bufs.payload = append(bufs.payload, expiryStr...)
bufs.payload = append(bufs.payload, 0)
bufs.payload = append(bufs.payload, userID...)
bufs.payload = append(bufs.payload, 0)
bufs.payload = append(bufs.payload, channel...)
bufs.payload = append(bufs.payload, 0)
hex.Encode(bufs.hexBuf, keysHash[:])
bufs.payload = append(bufs.payload, bufs.hexBuf...)
mac.Write(bufs.payload)
hex.Encode(bufs.expectedHex, mac.Sum(nil))
result := hmac.Equal([]byte(hmacHex), bufs.expectedHex)
v.macPool.Put(mac)
v.bufPool.Put(bufs)
return result
}

View File

@@ -0,0 +1,332 @@
package client
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"testing"
"time"
"github.com/cristalhq/jwt/v5"
sjson "github.com/segmentio/encoding/json"
)
func generateKeys(n int) []string {
keys := make([]string, n)
for i := range keys {
keys[i] = fmt.Sprintf("item_%05d", i)
}
return keys
}
// HMAC signature: create.
func createTrackSignature(secret, channel string, keys []string, userID string, iat, expiry int64) string {
keysHash := sha256.Sum256([]byte(strings.Join(keys, "\x00")))
// Inner payload uses NUL separators; outer signature stays ':'-separated.
payload := fmt.Sprintf("%d\x00%d\x00%s\x00%s\x00%x", iat, expiry, userID, channel, keysHash)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
return fmt.Sprintf("%d:%d:%x", iat, expiry, mac.Sum(nil))
}
// JWT: claims struct that includes keys.
type trackClaims struct {
jwt.RegisteredClaims
Channel string `json:"channel"`
Keys []string `json:"keys"`
}
func BenchmarkTrackSignature_HMAC_Create_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now().Unix()
expiry := now + 3600
b.ResetTimer()
for i := 0; i < b.N; i++ {
createTrackSignature(secret, channel, keys, userID, now, expiry)
}
}
func BenchmarkTrackSignature_HMAC_Create_Reuse_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now().Unix()
expiry := now + 3600
// Pre-compute keys hash.
keysHash := sha256.Sum256([]byte(strings.Join(keys, "\x00")))
// Reuse HMAC hasher and buffers.
mac := hmac.New(sha256.New, []byte(secret))
payload := make([]byte, 0, 256)
result := make([]byte, 0, 128)
hexBuf := make([]byte, hex.EncodedLen(sha256.Size))
b.ResetTimer()
for i := 0; i < b.N; i++ {
mac.Reset()
// Inner payload uses NUL separators; outer signature stays ':'-separated.
payload = payload[:0]
payload = strconv.AppendInt(payload, now, 10)
payload = append(payload, 0)
payload = strconv.AppendInt(payload, expiry, 10)
payload = append(payload, 0)
payload = append(payload, userID...)
payload = append(payload, 0)
payload = append(payload, channel...)
payload = append(payload, 0)
hex.Encode(hexBuf, keysHash[:])
payload = append(payload, hexBuf...)
mac.Write(payload)
result = result[:0]
result = strconv.AppendInt(result, now, 10)
result = append(result, ':')
result = strconv.AppendInt(result, expiry, 10)
result = append(result, ':')
hex.Encode(hexBuf, mac.Sum(nil))
result = append(result, hexBuf...)
_ = string(result)
}
}
func BenchmarkTrackSignature_HMAC_Verify_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now().Unix()
expiry := now + 3600
sig := createTrackSignature(secret, channel, keys, userID, now, expiry)
b.ResetTimer()
for i := 0; i < b.N; i++ {
if !verifyTrackSignature(secret, channel, sig, keys, userID) {
b.Fatal("verification failed")
}
}
}
func BenchmarkTrackSignature_HMAC_Verify_Reuse_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now().Unix()
expiry := now + 3600
sig := createTrackSignature(secret, channel, keys, userID, now, expiry)
// Pre-compute keys hash.
keysHash := sha256.Sum256([]byte(strings.Join(keys, "\x00")))
// Reuse HMAC hasher and buffers.
mac := hmac.New(sha256.New, []byte(secret))
payload := make([]byte, 0, 256)
hexBuf := make([]byte, hex.EncodedLen(sha256.Size))
expectedHex := make([]byte, hex.EncodedLen(sha256.Size))
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Parse iat:expiry:hmac from signature.
first := strings.IndexByte(sig, ':')
rest := sig[first+1:]
second := strings.IndexByte(rest, ':')
iatStr := sig[:first]
expiryStr := rest[:second]
hmacHex := rest[second+1:]
mac.Reset()
// Inner payload uses NUL separators; outer signature parsed with ':' above.
payload = payload[:0]
payload = append(payload, iatStr...)
payload = append(payload, 0)
payload = append(payload, expiryStr...)
payload = append(payload, 0)
payload = append(payload, userID...)
payload = append(payload, 0)
payload = append(payload, channel...)
payload = append(payload, 0)
hex.Encode(hexBuf, keysHash[:])
payload = append(payload, hexBuf...)
mac.Write(payload)
hex.Encode(expectedHex, mac.Sum(nil))
if !hmac.Equal([]byte(hmacHex), expectedHex) {
b.Fatal("verification failed")
}
}
}
func BenchmarkTrackSignature_JWT_Create_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now()
expiry := now.Add(time.Hour)
signer, err := jwt.NewSignerHS(jwt.HS256, []byte(secret))
if err != nil {
b.Fatal(err)
}
builder := jwt.NewBuilder(signer)
claims := &trackClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiry),
},
Channel: channel,
Keys: keys,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := builder.Build(claims)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkTrackSignature_JWT_Create_Segmentio_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now()
expiry := now.Add(time.Hour)
signer, err := jwt.NewSignerHS(jwt.HS256, []byte(secret))
if err != nil {
b.Fatal(err)
}
builder := jwt.NewBuilder(signer)
claims := &trackClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiry),
},
Channel: channel,
Keys: keys,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
rawClaims, err := sjson.Marshal(claims)
if err != nil {
b.Fatal(err)
}
_, err = builder.Build(rawClaims)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkTrackSignature_JWT_Verify_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now()
expiry := now.Add(time.Hour)
signer, err := jwt.NewSignerHS(jwt.HS256, []byte(secret))
if err != nil {
b.Fatal(err)
}
builder := jwt.NewBuilder(signer)
claims := &trackClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiry),
},
Channel: channel,
Keys: keys,
}
token, err := builder.Build(claims)
if err != nil {
b.Fatal(err)
}
tokenBytes := token.Bytes()
verifier, err := jwt.NewVerifierHS(jwt.HS256, []byte(secret))
if err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
parsedToken, err := jwt.Parse(tokenBytes, verifier)
if err != nil {
b.Fatal(err)
}
var parsed trackClaims
if err := parsedToken.DecodeClaims(&parsed); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkTrackSignature_JWT_Verify_Segmentio_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now()
expiry := now.Add(time.Hour)
signer, err := jwt.NewSignerHS(jwt.HS256, []byte(secret))
if err != nil {
b.Fatal(err)
}
builder := jwt.NewBuilder(signer)
claims := &trackClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiry),
},
Channel: channel,
Keys: keys,
}
token, err := builder.Build(claims)
if err != nil {
b.Fatal(err)
}
tokenBytes := token.Bytes()
verifier, err := jwt.NewVerifierHS(jwt.HS256, []byte(secret))
if err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
parsedToken, err := jwt.Parse(tokenBytes, verifier)
if err != nil {
b.Fatal(err)
}
var parsed trackClaims
if _, err := sjson.Parse(parsedToken.Claims(), &parsed, sjson.ZeroCopy); err != nil {
b.Fatal(err)
}
}
}

View File

@@ -0,0 +1,229 @@
package client
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
const testSecret = "test-secret-key"
func makeTestSignature(secret, channel string, keys []string, userID string, iat, expiry int64) string {
keysHash := sha256.Sum256([]byte(strings.Join(keys, "\x00")))
// Inner payload uses NUL separators so colons in userID/channel can't
// shift fields. Outer signature stays ':'-separated (colon-free fields).
payload := fmt.Sprintf("%d\x00%d\x00%s\x00%s\x00%x", iat, expiry, userID, channel, keysHash)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
return fmt.Sprintf("%d:%d:%x", iat, expiry, mac.Sum(nil))
}
func TestTrackSignature_ValidHMAC(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"key1", "key2"}, "user1", now, expiry)
require.True(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"key1", "key2"}, "user1"))
}
func TestTrackSignature_InvalidHMAC(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"key1", "key2"}, "user1", now, expiry)
// Tamper with the signature by flipping its last hex character to a
// different one. Replacing it with a fixed value would be a no-op when the
// signature already happens to end in that value (~1/256 of runs), which made
// this test flaky.
repl := byte('f')
if sig[len(sig)-1] == 'f' {
repl = 'e'
}
sig = sig[:len(sig)-1] + string(repl)
require.False(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"key1", "key2"}, "user1"))
}
func TestTrackSignature_ExpiredSignature(t *testing.T) {
now := time.Now().Unix()
iat := now - 7200
expiry := now - 3600 // Expired 1 hour ago.
sig := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "user1", iat, expiry)
// Signature is valid HMAC-wise but the caller (OnTrack) would check expiry.
// verifyTrackSignature itself only checks HMAC, not expiry.
require.True(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"key1"}, "user1"))
// But parseSignatureTimestamps should return the expired value.
_, parsedExpiry := parseSignatureTimestamps(sig)
require.True(t, parsedExpiry < now)
}
func TestTrackSignature_DifferentKeys(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"keyA", "keyB"}, "user1", now, expiry)
// Client sends different keys — should fail.
require.False(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"keyA", "keyC"}, "user1"))
}
func TestTrackSignature_DifferentUser(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "user1", now, expiry)
// Different user — should fail.
require.False(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"key1"}, "user2"))
}
func TestTrackSignature_AnonymousUser(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "", now, expiry)
require.True(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"key1"}, ""))
}
func TestTrackSignature_KeyOrderSensitive(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
// Sign with keys [B, A].
sig := makeTestSignature(testSecret, "test:channel", []string{"keyB", "keyA"}, "user1", now, expiry)
// Verify with keys in the same order — succeeds.
require.True(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"keyB", "keyA"}, "user1"))
// Verify with keys in a different order — fails. Signature is bound to wire order, not set membership.
require.False(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"keyA", "keyB"}, "user1"))
}
func TestTrackSignature_EmptySignature(t *testing.T) {
require.False(t, verifyTrackSignature(testSecret, "test:channel", "", []string{"key1"}, "user1"))
}
func TestTrackSignature_MalformedSignature(t *testing.T) {
require.False(t, verifyTrackSignature(testSecret, "test:channel", "not-a-valid-sig", []string{"key1"}, "user1"))
require.False(t, verifyTrackSignature(testSecret, "test:channel", "123", []string{"key1"}, "user1"))
}
func TestTrackSignature_WrongSecret(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "user1", now, expiry)
require.False(t, verifyTrackSignature("wrong-secret", "test:channel", sig, []string{"key1"}, "user1"))
}
func TestTrackSignature_WrongChannel(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "user1", now, expiry)
require.False(t, verifyTrackSignature(testSecret, "other:channel", sig, []string{"key1"}, "user1"))
}
func TestParseSignatureTimestamps(t *testing.T) {
iat, expiry := parseSignatureTimestamps("1000:2000:abcdef")
require.Equal(t, int64(1000), iat)
require.Equal(t, int64(2000), expiry)
}
func TestParseSignatureTimestamps_Invalid(t *testing.T) {
tests := []string{
"",
"abc",
"1000",
"1000:",
"1000:abc:def",
"abc:2000:def",
}
for _, sig := range tests {
iat, expiry := parseSignatureTimestamps(sig)
require.Equal(t, int64(0), iat, "sig=%q", sig)
require.Equal(t, int64(0), expiry, "sig=%q", sig)
}
}
func TestTrackSignature_SingleKey(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "ch", []string{"only-key"}, "u", now, expiry)
require.True(t, verifyTrackSignature(testSecret, "ch", sig, []string{"only-key"}, "u"))
}
func TestTrackSignature_ManyKeys(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
keys := make([]string, 100)
for i := range keys {
keys[i] = fmt.Sprintf("key-%03d", i)
}
sig := makeTestSignature(testSecret, "ch", keys, "user1", now, expiry)
require.True(t, verifyTrackSignature(testSecret, "ch", sig, keys, "user1"))
}
func TestParseSignatureTimestamps_RealSignature(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "ch", []string{"k"}, "u", now, expiry)
parsedIat, parsedExpiry := parseSignatureTimestamps(sig)
require.Equal(t, now, parsedIat)
require.Equal(t, expiry, parsedExpiry)
}
func TestTrackSignature_GracePeriod(t *testing.T) {
now := time.Now().Unix()
// Expired 3 seconds ago — within 5-second grace period.
iat := now - 3600
expiry := now - 3
sig := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "user1", iat, expiry)
// HMAC is valid (verifyTrackSignature doesn't check expiry).
require.True(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"key1"}, "user1"))
// The caller would check: expiry + gracePeriodSeconds >= now
_, parsedExpiry := parseSignatureTimestamps(sig)
withinGracePeriod := parsedExpiry+gracePeriodSeconds >= now
require.True(t, withinGracePeriod)
// Outside grace period.
iat2 := now - 7200
expiry2 := now - 60 // Expired 60 seconds ago, past grace period.
sig2 := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "user1", iat2, expiry2)
_, parsedExpiry2 := parseSignatureTimestamps(sig2)
withinGracePeriod2 := parsedExpiry2+gracePeriodSeconds >= now
require.False(t, withinGracePeriod2)
}
func TestVerifyTrackSignature_HexEncoding(t *testing.T) {
// Verify that the expected hex encoding matches between make and verify.
now := time.Now().Unix()
expiry := now + 3600
keys := []string{"key1"}
keysHash := sha256.Sum256([]byte(strings.Join(keys, "\x00")))
payload := fmt.Sprintf("%d\x00%d\x00%s\x00%s\x00%x", now, expiry, "u", "ch", keysHash)
mac := hmac.New(sha256.New, []byte(testSecret))
mac.Write([]byte(payload))
expectedHex := hex.EncodeToString(mac.Sum(nil))
sig := fmt.Sprintf("%d:%d:%s", now, expiry, expectedHex)
require.True(t, verifyTrackSignature(testSecret, "ch", sig, keys, "u"))
}
// TestTrackSignature_ColonNoAmbiguity is a regression test for the previous
// payload format that used ':' between every field. Under that format, a
// signature issued for (user="alice", channel="news:tech") and one issued
// for (user="alice:news", channel="tech") produced byte-identical payloads
// and therefore interchangeable signatures. NUL separation eliminates this.
func TestTrackSignature_ColonNoAmbiguity(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
keys := []string{"key1"}
sigA := makeTestSignature(testSecret, "news:tech", keys, "alice", now, expiry)
sigB := makeTestSignature(testSecret, "tech", keys, "alice:news", now, expiry)
require.NotEqual(t, sigA, sigB, "colon-overlap signatures must differ under NUL-separated payload")
// Signature A must NOT verify against B's (user, channel) tuple — proves
// the two are independent under the new format.
require.False(t, verifyTrackSignature(testSecret, "tech", sigA, keys, "alice:news"))
// And vice versa.
require.False(t, verifyTrackSignature(testSecret, "news:tech", sigB, keys, "alice"))
}

View File

@@ -0,0 +1 @@
package clientcontext

View File

@@ -0,0 +1,24 @@
package clientcontext
import (
"context"
)
type emulatedHeadersKey struct{}
// GetEmulatedHeadersFromContext returns emulated headers from context.
func GetEmulatedHeadersFromContext(ctx context.Context) (map[string]string, bool) {
if val := ctx.Value(emulatedHeadersKey{}); val != nil {
values, ok := val.(map[string]string)
return values, ok
}
return nil, false
}
// SetEmulatedHeadersToContext sets header map to context.
func SetEmulatedHeadersToContext(ctx context.Context, h map[string]string) context.Context {
if len(h) == 0 {
return ctx
}
return context.WithValue(ctx, emulatedHeadersKey{}, h)
}

View File

@@ -0,0 +1,5 @@
package clientstorage
const (
KeyMeta = "meta"
)

73
internal/config/cache.go Normal file
View File

@@ -0,0 +1,73 @@
package config
import (
"hash/fnv"
"sync/atomic"
"time"
)
type cacheItem struct {
channel string
value channelOptionsResult
expires int64
}
type cacheShard struct {
// index is unsigned on purpose: it only ever grows, and unsigned overflow
// wraps to zero cleanly. With a signed counter the wrap goes negative and
// the modulo below yields a negative buffer index, panicking the server
// after 2^31 Set calls land on the same shard.
index atomic.Uint32
size uint32
buffer []atomic.Value
}
type rollingCache struct {
shards []*cacheShard
}
func newRollingCache(shardSize int, shardCount int) *rollingCache {
rc := &rollingCache{
shards: make([]*cacheShard, shardCount),
}
for i := range rc.shards {
shard := &cacheShard{
size: uint32(shardSize),
buffer: make([]atomic.Value, shardSize),
}
for j := 0; j < shardSize; j++ {
shard.buffer[j].Store(&cacheItem{}) // Initialize with zero value.
}
rc.shards[i] = shard
}
return rc
}
func (c *rollingCache) shardForKey(key string) *cacheShard {
h := fnv.New64a()
_, _ = h.Write([]byte(key))
shardIndex := h.Sum64() % uint64(len(c.shards))
return c.shards[shardIndex]
}
func (c *rollingCache) Get(channel string) (channelOptionsResult, bool) {
shard := c.shardForKey(channel)
for i := 0; i < int(shard.size); i++ {
item := shard.buffer[i].Load().(*cacheItem)
if item.channel == channel && time.Now().UnixNano() < item.expires {
return item.value, true
}
}
return channelOptionsResult{}, false
}
func (c *rollingCache) Set(channel string, value channelOptionsResult, ttl time.Duration) {
shard := c.shardForKey(channel)
index := int(shard.index.Add(1) % shard.size)
item := &cacheItem{
channel: channel,
value: value,
expires: time.Now().Add(ttl).UnixNano(),
}
shard.buffer[index].Store(item)
}

View File

@@ -0,0 +1,80 @@
package config
import (
"math"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestRollingCache(t *testing.T) {
size := 30
shardCount := 1
cache := newRollingCache(size, shardCount)
// Testing Set.
channel := "channel1"
value := channelOptionsResult{nsName: "value1"}
ttl := 200 * time.Millisecond
cache.Set(channel, value, ttl)
// Testing Get after Set.
gotValue, ok := cache.Get(channel)
require.True(t, ok)
require.Equal(t, value, gotValue)
// Testing Get after expiry.
time.Sleep(400 * time.Millisecond)
_, ok = cache.Get(channel)
require.False(t, ok)
// Testing if data rolls over.
// Setting more data than cache size, to see if rolling over works without errors.
for i := 0; i < size+10; i++ {
channel := "channel" + strconv.Itoa(i)
value := channelOptionsResult{nsName: "value" + strconv.Itoa(i)}
cache.Set(channel, value, ttl)
}
// The first 10 items should have been rolled over and replaced.
// So, channel0, channel1, ... channel9 should no longer be in the cache.
for i := 0; i < 10; i++ {
channel := "channel" + strconv.Itoa(i)
_, ok = cache.Get(channel)
require.False(t, ok, channel)
}
// Whereas, the items from channel10 onwards should still be there.
for i := 10; i < size+10; i++ {
channel := "channel" + strconv.Itoa(i)
gotValue, ok := cache.Get(channel)
require.True(t, ok)
require.Equal(t, channelOptionsResult{nsName: "value" + strconv.Itoa(i)}, gotValue)
}
}
// TestRollingCacheIndexOverflow guards against the rolling index wrapping into
// negative values. The index is bumped on every Set, so on a long-running node
// it eventually overflows; with a signed counter the wrap made the modulo
// negative and Set panicked with "index out of range".
func TestRollingCacheIndexOverflow(t *testing.T) {
cache := newRollingCache(channelOptionsCacheSize, 1)
shard := cache.shardForKey("channel")
shard.index.Store(math.MaxUint32) // Next Set wraps the index.
ttl := time.Minute
for i := 0; i < 2*channelOptionsCacheSize; i++ {
channel := "channel" + strconv.Itoa(i)
cache.Set(channel, channelOptionsResult{nsName: "value" + strconv.Itoa(i)}, ttl)
}
// The most recent shardSize items must still be readable after the wrap.
for i := channelOptionsCacheSize; i < 2*channelOptionsCacheSize; i++ {
channel := "channel" + strconv.Itoa(i)
gotValue, ok := cache.Get(channel)
require.True(t, ok, channel)
require.Equal(t, channelOptionsResult{nsName: "value" + strconv.Itoa(i)}, gotValue)
}
}

429
internal/config/config.go Normal file
View File

@@ -0,0 +1,429 @@
// Package config contains Centrifugo Config and the code to load it.
package config
import (
"errors"
"fmt"
"os"
"reflect"
"regexp"
"slices"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config/envconfig"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/go-viper/mapstructure/v2"
"github.com/hashicorp/go-envparse"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
// Config contains configuration options of Centrifugo.
type Config struct {
// HTTP is a configuration for Centrifugo HTTP server.
HTTP configtypes.HTTPServer `mapstructure:"http_server" json:"http_server" envconfig:"http_server" toml:"http_server" yaml:"http_server" doc:"Configures the main HTTP server: listen address, ports, and the internal endpoints port."`
// Log is a configuration for logging.
Log configtypes.Log `mapstructure:"log" json:"log" envconfig:"log" toml:"log" yaml:"log" doc:"Configures logging: level and optional log file."`
// Engine is a configuration for Centrifugo engine. It's a handy combination of Broker and PresenceManager.
// Currently only memory and redis engines are supported – both implement all the features. For more granular
// control use `broker` and `presence_manager` options.
Engine configtypes.Engine `mapstructure:"engine" json:"engine" envconfig:"engine" toml:"engine" yaml:"engine" doc:"Selects the engine - a combined broker and presence manager. Supports <<memory>> (default) and <<redis>>. For finer control use <<broker>> and <<presence_manager>> instead."`
// Broker allows to configure a message broker to use. Broker is responsible for PUB/SUB functionality
// and channel message history and idempotency cache.
// By default, memory Broker is used. Memory broker is superfast, but it's not distributed and all
// data stored in memory (thus lost after node restart). Redis Broker provides seamless horizontal
// scalability, fault-tolerance, and persistence over Centrifugo restarts. Centrifugo also supports
// Nats Broker which only implements at most once PUB/SUB semantics.
Broker configtypes.Broker `mapstructure:"broker" json:"broker" envconfig:"broker" toml:"broker" yaml:"broker" doc:"Configures the message broker (PUB/SUB, history, idempotency cache) when set separately from the engine. Supports <<memory>> (default, not distributed, lost on restart), <<redis>> (scalable and persistent), and <<nats>> (at-most-once PUB/SUB only)."`
// PresenceManager allows to configure a presence manager to use. Presence manager is responsible for
// presence information storage and retrieval. By default, memory PresenceManager is used. Memory
// PresenceManager is superfast, but it's not distributed. Redis PresenceManager provides a seamless
// horizontal scalability.
PresenceManager configtypes.PresenceManager `mapstructure:"presence_manager" json:"presence_manager" envconfig:"presence_manager" toml:"presence_manager" yaml:"presence_manager" doc:"Configures the presence manager when set separately from the engine. Supports <<memory>> (default, not distributed) and <<redis>> (scalable)."`
// MapBroker allows to configure a map broker for synchronized keyed state channels.
// When enabled, clients can use map subscription types for channels in namespaces
// that have a map subscription_type.
MapBroker configtypes.MapBroker `mapstructure:"map_broker" json:"map_broker" envconfig:"map_broker" toml:"map_broker" yaml:"map_broker" doc:"Configures the map broker for synchronized keyed-state channels. Used by namespaces whose subscription type is <<map>>."`
// Controller is a configuration for custom Centrifugo Controller used for
// cross-node communication in multi-node clusters.
Controller configtypes.Controller `mapstructure:"controller" json:"controller" envconfig:"controller" toml:"controller" yaml:"controller" doc:"Configures the controller used for cross-node communication in multi-node clusters."`
// SharedPoll contains configuration for shared poll subscriptions.
SharedPoll configtypes.SharedPoll `mapstructure:"shared_poll" json:"shared_poll" envconfig:"shared_poll" toml:"shared_poll" yaml:"shared_poll" doc:"Configures shared poll subscriptions."`
// Client contains real-time client connection related configuration.
Client configtypes.Client `mapstructure:"client" json:"client" envconfig:"client" toml:"client" yaml:"client" doc:"Configures real-time client connections: auth, tokens, allowed origins, limits, proxies, and the insecure mode."`
// Channel contains real-time channel related configuration.
Channel configtypes.Channel `mapstructure:"channel" json:"channel" envconfig:"channel" toml:"channel" yaml:"channel" doc:"Configures channels and namespaces: separators, default options, and per-namespace behavior."`
// RPC is a configuration for client RPC calls.
RPC configtypes.RPC `mapstructure:"rpc" json:"rpc" envconfig:"rpc" toml:"rpc" yaml:"rpc" doc:"Configures client RPC calls and RPC namespaces."`
// Proxies is an array of proxies with custom names for the more granular control of channel-related events
// in different channel namespaces.
Proxies configtypes.NamedProxies `mapstructure:"proxies" default:"[]" json:"proxies" envconfig:"proxies" yaml:"proxies" toml:"proxies" doc:"Defines named proxies that namespaces can reference to forward channel-related events to your backend."`
// HttpAPI is a configuration for HTTP server API. It's enabled by default.
HttpAPI configtypes.HttpAPI `mapstructure:"http_api" json:"http_api" envconfig:"http_api" toml:"http_api" yaml:"http_api" doc:"Configures the server HTTP API (enabled by default): API key, insecure mode, and whether it's exposed on the external port."`
// GrpcAPI is a configuration for gRPC server API. It's disabled by default.
GrpcAPI configtypes.GrpcAPI `mapstructure:"grpc_api" json:"grpc_api" envconfig:"grpc_api" toml:"grpc_api" yaml:"grpc_api" doc:"Configures the server gRPC API (disabled by default): enable flag, port, TLS, and auth."`
// Consumers is a configuration for message queue consumers. For example, Centrifugo can consume
// messages from PostgreSQL transactional outbox table, or from Kafka topics.
Consumers configtypes.Consumers `mapstructure:"consumers" default:"[]" json:"consumers" envconfig:"consumers" toml:"consumers" yaml:"consumers" doc:"Defines message queue consumers that ingest commands from external sources such as a PostgreSQL outbox table or Kafka topics."`
// WebSocket configuration. This transport is enabled by default.
WebSocket configtypes.WebSocket `mapstructure:"websocket" json:"websocket" envconfig:"websocket" toml:"websocket" yaml:"websocket" doc:"Configures the bidirectional WebSocket transport (enabled by default): message size limits, compression, ping/pong, and write timeouts."`
// SSE is a configuration for Server-Sent Events based bidirectional emulation transport.
SSE configtypes.SSE `mapstructure:"sse" json:"sse" envconfig:"sse" toml:"sse" yaml:"sse" doc:"Configures the bidirectional Server-Sent Events (EventSource) transport, which relies on the emulation endpoint. Disabled by default."`
// HTTPStream is a configuration for HTTP streaming based bidirectional emulation transport.
HTTPStream configtypes.HTTPStream `mapstructure:"http_stream" json:"http_stream" envconfig:"http_stream" toml:"http_stream" yaml:"http_stream" doc:"Configures the bidirectional HTTP-streaming transport, which relies on the emulation endpoint. Disabled by default."`
// WebTransport is a configuration for WebTransport transport. EXPERIMENTAL.
WebTransport configtypes.WebTransport `mapstructure:"webtransport" json:"webtransport" envconfig:"webtransport" toml:"webtransport" yaml:"webtransport" doc:"Configures the bidirectional WebTransport (HTTP/3) transport. Experimental and disabled by default."`
// UniSSE is a configuration for unidirectional Server-Sent Events transport.
UniSSE configtypes.UniSSE `mapstructure:"uni_sse" json:"uni_sse" envconfig:"uni_sse" toml:"uni_sse" yaml:"uni_sse" doc:"Configures the unidirectional Server-Sent Events (EventSource) transport. Disabled by default."`
// UniHTTPStream is a configuration for unidirectional HTTP streaming transport.
UniHTTPStream configtypes.UniHTTPStream `mapstructure:"uni_http_stream" json:"uni_http_stream" envconfig:"uni_http_stream" toml:"uni_http_stream" yaml:"uni_http_stream" doc:"Configures the unidirectional HTTP-streaming transport. Disabled by default."`
// UniWS is a configuration for unidirectional WebSocket transport.
UniWS configtypes.UniWebSocket `mapstructure:"uni_websocket" json:"uni_websocket" envconfig:"uni_websocket" toml:"uni_websocket" yaml:"uni_websocket" doc:"Configures the unidirectional WebSocket transport. Disabled by default."`
// UniGRPC is a configuration for unidirectional gRPC transport.
UniGRPC configtypes.UniGRPC `mapstructure:"uni_grpc" json:"uni_grpc" envconfig:"uni_grpc" toml:"uni_grpc" yaml:"uni_grpc" doc:"Configures the unidirectional gRPC transport: enable flag, port, and TLS. Disabled by default."`
// Emulation endpoint is enabled automatically when at least one bidirectional emulation transport
// is configured (SSE or HTTP Stream).
Emulation configtypes.Emulation `mapstructure:"emulation" json:"emulation" envconfig:"emulation" toml:"emulation" yaml:"emulation" doc:"Configures the emulation endpoint used by bidirectional SSE and HTTP-streaming transports. Enabled automatically when one of those transports is enabled."`
// Admin web UI configuration.
Admin configtypes.Admin `mapstructure:"admin" json:"admin" envconfig:"admin" toml:"admin" yaml:"admin" doc:"Configures the admin web UI and its API endpoints."`
// Prometheus metrics configuration.
Prometheus configtypes.Prometheus `mapstructure:"prometheus" json:"prometheus" envconfig:"prometheus" toml:"prometheus" yaml:"prometheus" doc:"Configures the Prometheus metrics endpoint."`
// Health check endpoint configuration.
Health configtypes.Health `mapstructure:"health" json:"health" envconfig:"health" toml:"health" yaml:"health" doc:"Configures the health check endpoint."`
// Swagger documentation (for server HTTP API) configuration.
Swagger configtypes.Swagger `mapstructure:"swagger" json:"swagger" envconfig:"swagger" toml:"swagger" yaml:"swagger" doc:"Configures the Swagger UI endpoint describing the server HTTP API."`
// Debug helps to enable Go profiling endpoints.
Debug configtypes.Debug `mapstructure:"debug" json:"debug" envconfig:"debug" toml:"debug" yaml:"debug" doc:"Configures Go pprof profiling endpoints. For debugging only - do not expose publicly."`
// Init is a configuration for connection initialization endpoint.
Init configtypes.ConnInit `mapstructure:"init" json:"init" envconfig:"init" toml:"init" yaml:"init" doc:"Configures the connection initialization endpoint."`
// OpenTelemetry is a configuration for OpenTelemetry tracing.
OpenTelemetry configtypes.OpenTelemetry `mapstructure:"opentelemetry" json:"opentelemetry" envconfig:"opentelemetry" toml:"opentelemetry" yaml:"opentelemetry" doc:"Configures OpenTelemetry tracing export."`
// Graphite is a configuration for export metrics to Graphite.
Graphite configtypes.Graphite `mapstructure:"graphite" json:"graphite" envconfig:"graphite" toml:"graphite" yaml:"graphite" doc:"Configures exporting metrics to Graphite."`
// UsageStats is a configuration for usage stats sending.
UsageStats configtypes.UsageStats `mapstructure:"usage_stats" json:"usage_stats" envconfig:"usage_stats" toml:"usage_stats" yaml:"usage_stats" doc:"Configures anonymous usage statistics reporting. Set <<enabled>> to <<false>> to opt out."`
// Node is a configuration for Centrifugo Node as part of cluster.
Node configtypes.Node `mapstructure:"node" json:"node" envconfig:"node" toml:"node" yaml:"node" doc:"Configures this node as part of a Centrifugo cluster: node name and inter-node info settings."`
// Shutdown is a configuration for graceful shutdown.
Shutdown configtypes.Shutdown `mapstructure:"shutdown" json:"shutdown" envconfig:"shutdown" toml:"shutdown" yaml:"shutdown" doc:"Configures graceful shutdown behavior, such as the shutdown termination timeout."`
// PidFile is a path to write a file with Centrifugo process PID.
PidFile string `mapstructure:"pid_file" json:"pid_file" envconfig:"pid_file" toml:"pid_file" yaml:"pid_file" expose:"full" doc:"Path of a file to write the Centrifugo process PID to. Leave empty to disable."`
// EnableUnreleasedFeatures enables unreleased features. These features are not stable and may be removed even
// in minor release update. Evaluate and share feedback if you find some feature useful and want it to be stabilized.
EnableUnreleasedFeatures bool `mapstructure:"enable_unreleased_features" json:"enable_unreleased_features" envconfig:"enable_unreleased_features" toml:"enable_unreleased_features" yaml:"enable_unreleased_features" doc:"Enables unstable, unreleased features. These may change or be removed even in a minor release - do not rely on them in production."`
}
type Meta struct {
FileNotFound bool
UnknownKeys []string
UnknownEnvs []string
KnownEnvVars map[string]envconfig.VarInfo
DeprecationWarnings []string
}
func DefineFlags(rootCmd *cobra.Command) {
rootCmd.Flags().StringP("pid_file", "", "", "optional path to create PID file")
rootCmd.Flags().StringP("http_server.address", "a", "", "interface address to listen on")
rootCmd.Flags().StringP("http_server.port", "p", "8000", "port to bind HTTP server to")
rootCmd.Flags().StringP("http_server.internal_address", "", "", "custom interface address to listen on for internal endpoints")
rootCmd.Flags().StringP("http_server.internal_port", "", "", "custom port for internal endpoints")
rootCmd.Flags().StringP("engine.type", "", "memory", "engine to use: memory or redis")
rootCmd.Flags().BoolP("broker.enabled", "", false, "enable broker")
rootCmd.Flags().StringP("broker.type", "", "memory", "broker to use: memory, redis or nats")
rootCmd.Flags().BoolP("presence_manager.enabled", "", false, "enable presence manager")
rootCmd.Flags().StringP("presence_manager.type", "", "memory", "presence manager to use: memory or redis")
rootCmd.Flags().StringP("map_broker.type", "", "memory", "map broker to use: memory, redis or postgres")
rootCmd.Flags().StringP("log.level", "", "info", "set the log level: trace, debug, info, warn, error, fatal or none")
rootCmd.Flags().StringP("log.file", "", "", "optional log file - if not specified logs go to STDOUT")
rootCmd.Flags().BoolP("debug.enabled", "", false, "enable debug endpoints")
rootCmd.Flags().BoolP("admin.enabled", "", false, "enable admin web interface")
rootCmd.Flags().BoolP("admin.external", "", false, "expose admin web interface on external port")
rootCmd.Flags().BoolP("prometheus.enabled", "", false, "enable Prometheus metrics endpoint")
rootCmd.Flags().BoolP("swagger.enabled", "", false, "enable Swagger UI endpoint describing server HTTP API")
rootCmd.Flags().BoolP("health.enabled", "", false, "enable health check endpoint")
rootCmd.Flags().BoolP("uni_websocket.enabled", "", false, "enable unidirectional websocket endpoint")
rootCmd.Flags().BoolP("uni_sse.enabled", "", false, "enable unidirectional SSE (EventSource) endpoint")
rootCmd.Flags().BoolP("uni_http_stream.enabled", "", false, "enable unidirectional HTTP-streaming endpoint")
rootCmd.Flags().BoolP("sse.enabled", "", false, "enable bidirectional SSE (EventSource) endpoint (with emulation layer)")
rootCmd.Flags().BoolP("http_stream.enabled", "", false, "enable bidirectional HTTP-streaming endpoint (with emulation layer)")
rootCmd.Flags().BoolP("init.enabled", "", false, "enable connection init endpoint")
rootCmd.Flags().BoolP("client.insecure", "", false, "start in insecure client mode")
rootCmd.Flags().BoolP("http_api.insecure", "", false, "use insecure API mode")
rootCmd.Flags().BoolP("http_api.external", "", false, "expose API handler on external port")
rootCmd.Flags().BoolP("admin.insecure", "", false, "use insecure admin mode – no auth required for admin socket")
rootCmd.Flags().BoolP("grpc_api.enabled", "", false, "enable GRPC API server")
rootCmd.Flags().IntP("grpc_api.port", "", 10000, "port to bind GRPC API server to")
rootCmd.Flags().BoolP("uni_grpc.enabled", "", false, "enable unidirectional GRPC endpoint")
rootCmd.Flags().IntP("uni_grpc.port", "", 11000, "port to bind unidirectional GRPC server to")
}
func GetConfig(cmd *cobra.Command, configFile string) (Config, Meta, error) {
v := viper.NewWithOptions(viper.WithDecodeHook(mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
configtypes.StringToDurationHookFunc(),
configtypes.StringToPEMDataHookFunc(),
configtypes.StringToMapStringStringHookFunc(),
configtypes.StringToStringKeyValuesHookFunc(),
)))
if cmd != nil {
bindPFlags := []string{
"pid_file", "http_server.port", "http_server.address", "http_server.internal_port",
"http_server.internal_address", "log.level", "log.file", "engine.type", "broker.enabled", "broker.type",
"presence_manager.enabled", "presence_manager.type", "map_broker.type",
"debug.enabled", "admin.enabled", "admin.external",
"admin.insecure", "client.insecure", "http_api.insecure", "http_api.external", "prometheus.enabled",
"health.enabled", "grpc_api.enabled", "grpc_api.port", "uni_grpc.enabled", "uni_grpc.port",
"uni_websocket.enabled", "uni_sse.enabled", "uni_http_stream.enabled", "sse.enabled", "http_stream.enabled",
"swagger.enabled", "init.enabled",
}
for _, flag := range bindPFlags {
_ = v.BindPFlag(flag, cmd.Flags().Lookup(flag))
}
}
meta := Meta{}
if configFile != "" {
v.SetConfigFile(configFile)
err := v.ReadInConfig()
if err != nil {
var configFileNotFoundError *os.PathError
if errors.As(err, &configFileNotFoundError) {
meta.FileNotFound = true
} else {
return Config{}, Meta{}, fmt.Errorf("error reading config file %s: %w", configFile, err)
}
}
}
conf := &Config{}
err := v.Unmarshal(conf)
if err != nil {
return Config{}, Meta{}, fmt.Errorf("error unmarshalling config: %w", err)
}
var flagsSet []string
if cmd != nil {
cmd.Flags().Visit(func(f *pflag.Flag) {
if !slices.Contains(flagsSet, f.Name) {
flagsSet = append(flagsSet, f.Name) // pid_file, http_server.port, etc.
}
})
}
knownEnvVars := map[string]envconfig.VarInfo{}
varInfo, err := envconfig.ProcessWithoutNestedKeys("CENTRIFUGO", conf, flagsSet)
if err != nil {
return Config{}, Meta{}, fmt.Errorf("error processing env: %w", err)
}
extendKnownEnvVars(knownEnvVars, varInfo)
for i, item := range conf.Channel.Namespaces {
varInfo, err = envconfig.Process("CENTRIFUGO_CHANNEL_NAMESPACES_"+configtypes.NameForEnv(item.Name), &item)
if err != nil {
return Config{}, Meta{}, fmt.Errorf("error processing env namespaces: %w", err)
}
conf.Channel.Namespaces[i] = item
extendKnownEnvVars(knownEnvVars, varInfo)
}
for i, item := range conf.RPC.Namespaces {
varInfo, err = envconfig.Process("CENTRIFUGO_RPC_NAMESPACES_"+configtypes.NameForEnv(item.Name), &item)
if err != nil {
return Config{}, Meta{}, fmt.Errorf("error processing env rpc namespaces: %w", err)
}
conf.RPC.Namespaces[i] = item
extendKnownEnvVars(knownEnvVars, varInfo)
}
for i, item := range conf.Proxies {
varInfo, err = envconfig.Process("CENTRIFUGO_PROXIES_"+configtypes.NameForEnv(item.Name), &item)
if err != nil {
return Config{}, Meta{}, fmt.Errorf("error processing env named proxies: %w", err)
}
conf.Proxies[i] = item
extendKnownEnvVars(knownEnvVars, varInfo)
}
for i, item := range conf.Consumers {
varInfo, err = envconfig.Process("CENTRIFUGO_CONSUMERS_"+configtypes.NameForEnv(item.Name), &item)
if err != nil {
return Config{}, Meta{}, fmt.Errorf("error processing env consumers: %w", err)
}
conf.Consumers[i] = item
extendKnownEnvVars(knownEnvVars, varInfo)
}
meta.UnknownKeys = findUnknownKeys(v.AllSettings(), conf, "")
meta.UnknownEnvs = checkEnvironmentVars(knownEnvVars)
meta.KnownEnvVars = knownEnvVars
cfg := *conf
cfg, deprecationWarnings := applyConfigMigrations(cfg)
meta.DeprecationWarnings = deprecationWarnings
return cfg, meta, nil
}
func extendKnownEnvVars(knownEnvVars map[string]envconfig.VarInfo, varInfo []envconfig.VarInfo) {
for _, info := range varInfo {
knownEnvVars[info.Key] = info
}
}
// findValidKeys recursively finds valid keys in a struct, including embedded structs
func findValidKeys(typ reflect.Type, validKeys map[string]reflect.StructField) {
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
tag := field.Tag.Get("mapstructure")
if tag != "" && tag != ",squash" {
// Normal field, add it to validKeys.
validKeys[tag] = field
} else if field.Anonymous && strings.Contains(tag, "squash") {
// Handle embedded fields with "squash".
embeddedType := field.Type
if embeddedType.Kind() == reflect.Pointer {
embeddedType = embeddedType.Elem()
}
if embeddedType.Kind() == reflect.Struct {
// Recursively process the embedded struct
findValidKeys(embeddedType, validKeys)
}
}
}
}
func findUnknownKeys(data map[string]interface{}, configStruct interface{}, parentKey string) []string {
var unknownKeys []string
val := reflect.ValueOf(configStruct)
if val.Kind() == reflect.Pointer && val.IsNil() {
// Create an instance if the struct pointer is nil to avoid panic.
val = reflect.New(val.Type().Elem())
}
if val.Kind() == reflect.Pointer {
val = val.Elem()
}
typ := val.Type()
// Build a set of valid keys from the struct's mapstructure tags, including embedded structs.
validKeys := make(map[string]reflect.StructField)
findValidKeys(typ, validKeys)
// Check each key in the map to see if it's in the valid keys set
for key, value := range data {
if field, exists := validKeys[key]; exists {
fieldValue := val.FieldByName(field.Name)
if (fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && fieldValue.Type().Elem().Kind() == reflect.Struct)) && !field.Anonymous {
if nestedMap, ok := value.(map[string]interface{}); ok {
// Handle pointers to structs specifically
if fieldValue.Kind() == reflect.Pointer && fieldValue.IsNil() {
fieldValue.Set(reflect.New(fieldValue.Type().Elem())) // Create new struct if nil
}
nestedStruct := fieldValue.Interface()
if fieldValue.Kind() == reflect.Pointer {
nestedStruct = fieldValue.Elem().Interface()
}
unknownKeys = append(unknownKeys, findUnknownKeys(nestedMap, nestedStruct, appendKeyPath(parentKey, key))...)
}
} else if fieldValue.Kind() == reflect.Slice {
// Handle each element in the slice if it is a map
if slice, ok := value.([]interface{}); ok {
for i, elem := range slice {
if elemMap, ok := elem.(map[string]interface{}); ok {
elementType := fieldValue.Type().Elem()
if elementType.Kind() == reflect.Pointer {
elementType = elementType.Elem()
}
if elementType.Kind() == reflect.Struct {
nestedStruct := reflect.New(elementType).Interface()
unknownKeys = append(unknownKeys, findUnknownKeys(elemMap, nestedStruct, appendKeyPath(appendKeyPath(parentKey, key), fmt.Sprintf("[%d]", i)))...)
}
}
}
}
}
} else {
unknownKeys = append(unknownKeys, appendKeyPath(parentKey, key))
}
}
return unknownKeys
}
func appendKeyPath(parent, key string) string {
if parent == "" {
return key
}
return parent + "." + key
}
func checkEnvironmentVars(knownEnvVars map[string]envconfig.VarInfo) []string {
var unknownEnvs []string
envPrefix := "CENTRIFUGO_"
envVars := os.Environ()
for _, envVar := range envVars {
kv, err := envparse.Parse(strings.NewReader(envVar))
if err != nil {
continue
}
for envKey := range kv {
if !strings.HasPrefix(envKey, envPrefix) {
continue
}
// Kubernetes automatically adds some variables which are not used by Centrifugo
// itself. We skip warnings about them.
if isKubernetesEnvVar(envKey) {
continue
}
// Centrifugo can extrapolate ${CENTRIFUGO_VAR_XXX} from env inside MapStringString type.
if isCustomEnvVar(envKey) {
continue
}
if _, ok := knownEnvVars[envKey]; !ok {
unknownEnvs = append(unknownEnvs, envKey)
}
}
}
return unknownEnvs
}
// k8sEnvRegex is used to filter out Kubernetes-injected environment variables.
// Centrifugo automatically scans environment variables starting with CENTRIFUGO_ and converts them to configuration options.
// However, Kubernetes automatically injects environment variables, so this regular expression filters out Kubernetes-injected environment variables.
// See isKubernetesEnvVar function for usage.
var k8sEnvRegex = regexp.MustCompile(`^CENTRIFUGO(?:_[A-Z0-9_]+)?_(PORT|SERVICE_)`)
func isKubernetesEnvVar(envKey string) bool {
return k8sEnvRegex.MatchString(envKey)
}
func isCustomEnvVar(envKey string) bool {
// Custom environment variables should start with "CENTRIFUGO_VAR_" prefix.
// This function checks if the environment variable is a custom one.
return strings.HasPrefix(envKey, "CENTRIFUGO_VAR_")
}
// DefaultConfig is a helper to be used in tests.
func DefaultConfig() Config {
conf, _, err := GetConfig(nil, "")
if err != nil {
panic("error during getting default config: " + err.Error())
}
return conf
}

View File

@@ -0,0 +1,164 @@
package config
import (
"os"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/stretchr/testify/require"
)
func getConfig(t *testing.T, configFile string) (Config, Meta) {
t.Helper()
conf, meta, err := GetConfig(nil, configFile)
require.NoError(t, err)
return conf, meta
}
func checkConfig(t *testing.T, conf Config) {
t.Helper()
require.NotNil(t, conf)
require.True(t, conf.HTTP.TLS.Enabled)
require.NotZero(t, conf.HTTP.TLS.ServerCAPem)
_, err := conf.HTTP.TLS.ToGoTLSConfig("test")
require.NoError(t, err)
require.Equal(t, "https://example.com/jwks", conf.Client.Token.JWKSPublicEndpoint)
require.Len(t, conf.Client.AllowedOrigins, 1)
require.Equal(t, "http://localhost:3000", conf.Client.AllowedOrigins[0])
require.Len(t, conf.Channel.Namespaces, 2)
require.Len(t, conf.Consumers, 1)
require.Equal(t, "kafka", conf.Consumers[0].Type)
require.Equal(t, "ppp", conf.Proxies[0].Name)
require.Equal(t, configtypes.Duration(time.Second), conf.Proxies[0].Timeout)
require.Equal(t, true, conf.Consumers[0].Kafka.TLS.Enabled)
require.Equal(t, configtypes.Duration(2*time.Second), conf.WebSocket.WriteTimeout)
require.Equal(t, "redis", conf.Engine.Type)
require.Equal(t, 30*time.Second, time.Duration(conf.Engine.Redis.PresenceTTL))
require.Equal(t, []string{"redis:6379"}, conf.Engine.Redis.Address)
require.Equal(t, configtypes.MapStringString(map[string]string{"x": "y"}), conf.Client.Proxy.Connect.HTTP.StaticHeaders)
require.Equal(t, configtypes.MapStringString(map[string]string{"X": "y"}), conf.Client.Proxy.Refresh.HTTP.StaticHeaders)
}
func TestConfigJSON(t *testing.T) {
conf, meta := getConfig(t, "testdata/config.json")
checkConfig(t, conf)
t.Log("unknown keys", meta.UnknownKeys)
t.Log("unknown keys", meta.UnknownEnvs)
require.Len(t, meta.UnknownKeys, 0)
require.Len(t, meta.UnknownEnvs, 0)
}
func TestConfigYAML(t *testing.T) {
conf, _ := getConfig(t, "testdata/config.yaml")
checkConfig(t, conf)
}
func TestConfigTOML(t *testing.T) {
conf, _ := getConfig(t, "testdata/config.toml")
checkConfig(t, conf)
}
func TestConfigEnvVars(t *testing.T) {
// Set environment variables for the test.
_ = os.Setenv("CENTRIFUGO_CLIENT_ALLOWED_ORIGINS", "* http://localhost:4000")
_ = os.Setenv("CENTRIFUGO_CLIENT_TOKEN_JWKS_PUBLIC_ENDPOINT", "https://example.com/jwks/new")
_ = os.Setenv("CENTRIFUGO_CONSUMERS_KAFKA_KAFKA_TLS_ENABLED", "false")
_ = os.Setenv("CENTRIFUGO_UNKNOWN_ENV", "1")
_ = os.Setenv("CENTRIFUGO_CHANNEL_NAMESPACES", `[{"name": "env"}]`)
_ = os.Setenv("CENTRIFUGO_CLIENT_PROXY_CONNECT_HTTP_STATIC_HEADERS", `{"key": "value"}`)
_ = os.Setenv("CENTRIFUGO_WEBSOCKET_WRITE_TIMEOUT", `300ms`)
_ = os.Setenv("CENTRIFUGO_PROXIES", `[]`)
_ = os.Setenv("CENTRIFUGO_ENABLE_UNRELEASED_FEATURES", ``) // Empty options should be treated as unset.
_ = os.Setenv("CENTRIFUGO_RPC_NAMESPACES", ``) // Empty options should be treated as unset.
_ = os.Setenv("CENTRIFUGO_CLIENT_PROXY_CONNECT_TIMEOUT", ``) // Empty unset value should inherit default.
defer func() {
_ = os.Unsetenv("CENTRIFUGO_CONSUMERS_KAFKA_KAFKA_TLS_ENABLED")
_ = os.Unsetenv("CENTRIFUGO_UNKNOWN_ENV")
_ = os.Unsetenv("CENTRIFUGO_CLIENT_ALLOWED_ORIGINS")
_ = os.Unsetenv("CENTRIFUGO_CLIENT_TOKEN_JWKS_PUBLIC_ENDPOINT")
_ = os.Unsetenv("CENTRIFUGO_CHANNEL_NAMESPACES")
_ = os.Unsetenv("CENTRIFUGO_CLIENT_PROXY_CONNECT_HTTP_STATIC_HEADERS")
_ = os.Unsetenv("CENTRIFUGO_WEBSOCKET_WRITE_TIMEOUT")
_ = os.Unsetenv("CENTRIFUGO_PROXIES")
_ = os.Unsetenv("CENTRIFUGO_ENABLE_UNRELEASED_FEATURES")
_ = os.Unsetenv("CENTRIFUGO_RPC_NAMESPACES")
_ = os.Unsetenv("CENTRIFUGO_CLIENT_PROXY_CONNECT_TIMEOUT")
}()
// Proceed with the test.
conf, meta := getConfig(t, "testdata/config.json")
require.Equal(t, "https://example.com/jwks/new", conf.Client.Token.JWKSPublicEndpoint)
require.Equal(t, false, conf.Consumers[0].Kafka.TLS.Enabled)
require.Equal(t, []string{"*", "http://localhost:4000"}, conf.Client.AllowedOrigins)
require.Len(t, conf.Channel.Namespaces, 1)
require.Equal(t, "env", conf.Channel.Namespaces[0].Name)
require.Len(t, meta.UnknownEnvs, 1)
require.Len(t, meta.UnknownKeys, 0)
require.Contains(t, meta.UnknownEnvs, "CENTRIFUGO_UNKNOWN_ENV")
require.Equal(t, configtypes.MapStringString(map[string]string{"key": "value"}), conf.Client.Proxy.Connect.HTTP.StaticHeaders)
require.Equal(t, configtypes.Duration(300*time.Millisecond), conf.WebSocket.WriteTimeout)
require.Equal(t, time.Second, time.Duration(conf.Client.Proxy.Connect.Timeout))
require.Len(t, conf.RPC.Namespaces, 0)
require.Len(t, conf.Proxies, 0)
}
func TestConfigEnvVarsNamedArray(t *testing.T) {
_ = os.Setenv("CENTRIFUGO_RPC_NAMESPACES", `rpc1 rpc2`)
_ = os.Setenv("CENTRIFUGO_RPC_NAMESPACES_RPC1_PROXY_ENABLED", `true`)
_ = os.Setenv("CENTRIFUGO_RPC_NAMESPACES_RPC1_PROXY_NAME", `custom`)
defer func() {
_ = os.Unsetenv("CENTRIFUGO_RPC_NAMESPACES")
_ = os.Unsetenv("CENTRIFUGO_RPC_NAMESPACES_RPC1_PROXY_ENABLED")
_ = os.Unsetenv("CENTRIFUGO_RPC_NAMESPACES_RPC1_PROXY_NAME")
}()
conf, meta := getConfig(t, "testdata/config.json")
require.Len(t, meta.UnknownEnvs, 0)
require.Len(t, conf.RPC.Namespaces, 2)
require.Equal(t, "rpc1", conf.RPC.Namespaces[0].Name)
require.Equal(t, "rpc2", conf.RPC.Namespaces[1].Name)
require.Equal(t, true, conf.RPC.Namespaces[0].ProxyEnabled)
require.Equal(t, "custom", conf.RPC.Namespaces[0].ProxyName)
}
// TestIsKubernetesEnvVar validates the isKubernetesEnvVar function.
func TestIsKubernetesEnvVar(t *testing.T) {
tests := []struct {
envKey string
expected bool
}{
{"CENTRIFUGO_PORT_11000_TCP", true},
{"CENTRIFUGO_SERVICE_", true},
{"CENTRIFUGO_PORT_11000", true},
{"CENTRIFUGO_SERVICE_22000", true},
{"CENTRIFUGO_APP_PORT_", true},
{"CENTRIFUGO_APP_SERVICE_", true},
{"CENTRIFUGO_APP_SERVICE_PORT_INTERNAL", true},
{"CENTRIFUGO_APP_PORT", true},
{"CENTRIFUGO_P0E8NNPNYU_PORT_9000_TCP_PROTO", true},
{"CENTRIFUGO_P0E8NNPNYU_PORT_11000_TCP_PORT", true},
{"CENTRIFUGO_P0E8NNPNYU_PORT_11000_TCP", true},
{"CENTRIFUGO_P0E8NNPNYU_SERVICE_PORT_EXTERNAL", true},
{"CENTRIFUGO_P0E8NNPNYU_PORT_11000_TCP_ADDR", true},
{"CENTRIFUGO_P0E8NNPNYU_SERVICE_HOST", true},
{"CENTRIFUGO_P0E8NNPNYU_PORT", true},
{"CENTRIFUGO_P0E8NNPNYU_SERVICE_PORT", true},
{"CENTRIFUGO_P0E8NNPNYU_SERVICE_PORT_GRPC", true},
{"FOO_CENTRIFUGO_PORT_", false},
{"CENTRIFUGO__PORT_", false},
{"CENTRIFUGO_INVALID_", false},
{"CENTRIFUGO_APP", false},
{"CENTRIFUGO_APP_INVALID_", false},
{"centrifugo_port_", false},
{"CENTRIFUGO_LOG_FILE", false},
}
for _, tt := range tests {
t.Run(tt.envKey, func(t *testing.T) {
result := isKubernetesEnvVar(tt.envKey)
require.Equal(t, tt.expected, result, "Test failed for input: %s", tt.envKey)
})
}
}

6
internal/config/const.go Normal file
View File

@@ -0,0 +1,6 @@
package config
const (
DefaultProxyName = "default"
TransportErrorMode = "transport"
)

View File

@@ -0,0 +1,268 @@
package config
import (
"regexp"
"strings"
"sync/atomic"
"time"
"unicode"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
)
// Container wraps configuration providing sync access, reload and compilation of required parts.
type Container struct {
configValue atomic.Value
channelOptionsCache *rollingCache
ChannelOptionsCacheTTL time.Duration
}
const (
channelOptionsCacheSize = 8
channelOptionsCacheShards = 128
)
// NewContainer creates new Container.
func NewContainer(config Config) (*Container, error) {
preparedConfig, err := getPreparedConfig(config)
if err != nil {
return nil, err
}
c := &Container{
channelOptionsCache: newRollingCache(channelOptionsCacheSize, channelOptionsCacheShards),
}
c.configValue.Store(preparedConfig)
return c, nil
}
func getPreparedConfig(config Config) (*Config, error) {
if err := config.Validate(); err != nil {
return &config, err
}
config, err := buildCompiledRegexes(config)
if err != nil {
return &config, err
}
return &config, nil
}
// Reload node config.
func (n *Container) Reload(c Config) error {
preparedConfig, err := getPreparedConfig(c)
if err != nil {
return err
}
n.configValue.Store(preparedConfig)
return nil
}
func buildCompiledRegexes(config Config) (Config, error) {
if config.Channel.WithoutNamespace.ChannelRegex != "" {
p, err := regexp.Compile(config.Channel.WithoutNamespace.ChannelRegex)
if err != nil {
return config, err
}
config.Channel.WithoutNamespace.Compiled.CompiledChannelRegex = p
}
var namespaces []configtypes.ChannelNamespace
for _, ns := range config.Channel.Namespaces {
if ns.ChannelRegex == "" {
namespaces = append(namespaces, ns)
continue
}
p, err := regexp.Compile(ns.ChannelRegex)
if err != nil {
return config, err
}
ns.Compiled.CompiledChannelRegex = p
namespaces = append(namespaces, ns)
}
config.Channel.Namespaces = namespaces
return config, nil
}
// namespaceName returns namespace name from channel if exists.
func (n *Container) namespaceName(config *Config, ch string) (string, string) {
cTrim := strings.TrimPrefix(ch, config.Channel.PrivatePrefix)
if config.Channel.NamespaceBoundary != "" && strings.Contains(cTrim, config.Channel.NamespaceBoundary) {
parts := strings.SplitN(cTrim, config.Channel.NamespaceBoundary, 2)
return parts[0], parts[1]
}
return "", ch
}
type channelOptionsResult struct {
nsName string
rest string
chOpts configtypes.ChannelOptions
ok bool
err error
}
// ChannelOptions returns channel options for channel using current channel config.
func (n *Container) ChannelOptions(ch string) (string, string, configtypes.ChannelOptions, bool, error) {
if n.ChannelOptionsCacheTTL > 0 {
if res, ok := n.channelOptionsCache.Get(ch); ok {
return res.nsName, res.rest, res.chOpts, res.ok, res.err
}
}
cfg := n.configValue.Load().(*Config)
nsName, rest := n.namespaceName(cfg, ch)
chOpts, ok, err := channelOpts(cfg, nsName)
// Apply global publication_data_format default if not set at namespace level
if chOpts.PublicationDataFormat == "" && cfg.Channel.PublicationDataFormat != "" {
chOpts.PublicationDataFormat = cfg.Channel.PublicationDataFormat
}
res := channelOptionsResult{
nsName: nsName,
rest: rest,
chOpts: chOpts,
ok: ok,
err: err,
}
if n.ChannelOptionsCacheTTL > 0 {
n.channelOptionsCache.Set(ch, res, n.ChannelOptionsCacheTTL)
}
return res.nsName, res.rest, res.chOpts, res.ok, res.err
}
// ValidChannelName checks whether the channel name is valid for the resolved
// channel options. When channel_regex is set it's matched against rest - the part
// of the channel which follows the namespace, as returned by ChannelOptions. When
// no channel_regex is set the channel must only contain ASCII characters.
func (n *Container) ValidChannelName(ch string, rest string, chOpts configtypes.ChannelOptions) (bool, error) {
if chOpts.ChannelRegex != "" {
if !chOpts.Compiled.CompiledChannelRegex.MatchString(rest) {
return false, nil
}
} else if !isASCII(ch) {
return false, nil
}
return true, nil
}
func isASCII(s string) bool {
for _, c := range s {
if c > unicode.MaxASCII {
return false
}
}
return true
}
// NumNamespaces returns number of configured namespaces.
func (n *Container) NumNamespaces() int {
c := n.configValue.Load().(*Config)
return len(c.Channel.Namespaces)
}
// channelOpts searches for channel options for specified namespace key.
func channelOpts(c *Config, namespaceName string) (configtypes.ChannelOptions, bool, error) {
if namespaceName == "" {
return c.Channel.WithoutNamespace, true, nil
}
for _, n := range c.Channel.Namespaces {
if n.Name == namespaceName {
return n.ChannelOptions, true, nil
}
}
return configtypes.ChannelOptions{}, false, nil
}
// PersonalChannel returns personal channel for user based on node configuration.
func (n *Container) PersonalChannel(user string) string {
cfg := n.configValue.Load().(*Config)
if cfg.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace == "" {
return cfg.Channel.UserBoundary + user
}
return cfg.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace + cfg.Channel.NamespaceBoundary + cfg.Channel.UserBoundary + user
}
// Config returns a copy of node Config.
func (n *Container) Config() Config {
return *n.configValue.Load().(*Config)
}
// IsPrivateChannel checks if channel requires token to subscribe. In case of
// token-protected channel subscription request must contain a proper token.
func (n *Container) IsPrivateChannel(ch string) bool {
cfg := n.configValue.Load().(*Config)
if cfg.Channel.PrivatePrefix == "" {
return false
}
return strings.HasPrefix(ch, cfg.Channel.PrivatePrefix)
}
// IsUserLimited returns whether channel is user-limited.
func (n *Container) IsUserLimited(ch string) bool {
cfg := n.configValue.Load().(*Config)
userBoundary := cfg.Channel.UserBoundary
if userBoundary == "" {
return false
}
return strings.Contains(ch, userBoundary)
}
// UserAllowed checks if user can subscribe on channel - as channel
// can contain special part in the end to indicate which users allowed
// to subscribe on it.
func (n *Container) UserAllowed(ch string, user string) bool {
cfg := n.configValue.Load().(*Config)
userBoundary := cfg.Channel.UserBoundary
userSeparator := cfg.Channel.UserSeparator
if userBoundary == "" {
return true
}
if !strings.Contains(ch, userBoundary) {
return true
}
parts := strings.Split(ch, userBoundary)
if userSeparator == "" {
return parts[len(parts)-1] == user
}
allowedUsers := strings.Split(parts[len(parts)-1], userSeparator)
for _, allowedUser := range allowedUsers {
if user == allowedUser {
return true
}
}
return false
}
// rpcNamespaceName returns rpc namespace name from channel if exists.
func (n *Container) rpcNamespaceName(method string) string {
cfg := n.configValue.Load().(*Config)
if cfg.RPC.NamespaceBoundary != "" && strings.Contains(method, cfg.RPC.NamespaceBoundary) {
parts := strings.SplitN(method, cfg.RPC.NamespaceBoundary, 2)
return parts[0]
}
return ""
}
// RpcOptions returns rpc options for method using current config.
func (n *Container) RpcOptions(method string) (configtypes.RpcOptions, bool, error) {
cfg := n.configValue.Load().(*Config)
return rpcOpts(cfg, n.rpcNamespaceName(method))
}
// NumRpcNamespaces returns number of configured rpc namespaces.
func (n *Container) NumRpcNamespaces() int {
cfg := n.configValue.Load().(*Config)
return len(cfg.RPC.Namespaces)
}
// rpcOpts searches for channel options for specified namespace key.
func rpcOpts(c *Config, namespaceName string) (configtypes.RpcOptions, bool, error) {
if namespaceName == "" {
return c.RPC.WithoutNamespace, true, nil
}
for _, n := range c.RPC.Namespaces {
if n.Name == namespaceName {
return n.RpcOptions, true, nil
}
}
return configtypes.RpcOptions{}, false, nil
}

View File

@@ -0,0 +1,391 @@
package config
import (
"strconv"
"strings"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/stretchr/testify/require"
)
func defaultConfig(tb testing.TB) Config {
conf := DefaultConfig()
require.NotNil(tb, conf)
return conf
}
func TestChannelNotFound(t *testing.T) {
c := defaultConfig(t)
_, found, err := channelOpts(&c, "xxx")
require.False(t, found)
require.NoError(t, err)
}
func TestConfigValidateDefault(t *testing.T) {
err := defaultConfig(t).Validate()
require.NoError(t, err)
}
func TestConfigValidateInvalidNamespaceName(t *testing.T) {
c := defaultConfig(t)
c.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "invalid name",
ChannelOptions: configtypes.ChannelOptions{},
},
}
err := c.Validate()
require.Error(t, err)
}
func TestConfigCompiledChannelRegex(t *testing.T) {
c := defaultConfig(t)
c.Channel.WithoutNamespace.ChannelRegex = "^test$"
c.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "name1",
ChannelOptions: configtypes.ChannelOptions{
ChannelRegex: "^test_ns$",
},
},
{
Name: "name2",
ChannelOptions: configtypes.ChannelOptions{},
},
}
ruleContainer, err := NewContainer(c)
require.NoError(t, err)
require.NotNil(t, ruleContainer.Config().Channel.WithoutNamespace.CompiledChannelRegex)
require.NotNil(t, ruleContainer.Config().Channel.Namespaces[0].CompiledChannelRegex)
require.Nil(t, ruleContainer.Config().Channel.Namespaces[1].CompiledChannelRegex)
}
func TestConfigValidateDuplicateNamespaceName(t *testing.T) {
c := defaultConfig(t)
c.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "name",
ChannelOptions: configtypes.ChannelOptions{},
},
{
Name: "name",
ChannelOptions: configtypes.ChannelOptions{},
},
}
err := c.Validate()
require.Error(t, err)
}
func TestConfigValidateNoPersonalNamespace(t *testing.T) {
c := defaultConfig(t)
c.Channel.Namespaces = []configtypes.ChannelNamespace{}
c.Client.SubscribeToUserPersonalChannel.Enabled = true
c.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace = "name"
err := c.Validate()
require.Error(t, err)
}
func TestConfigValidatePersonalSingleConnectionMissingPresence(t *testing.T) {
c := defaultConfig(t)
c.Channel.Namespaces = []configtypes.ChannelNamespace{}
c.Client.SubscribeToUserPersonalChannel.Enabled = true
c.Client.SubscribeToUserPersonalChannel.SingleConnection = true
err := c.Validate()
require.Error(t, err)
}
func TestConfigValidatePersonalSingleConnectionOK(t *testing.T) {
c := defaultConfig(t)
c.Channel.Namespaces = []configtypes.ChannelNamespace{}
c.Client.SubscribeToUserPersonalChannel.Enabled = true
c.Client.SubscribeToUserPersonalChannel.SingleConnection = true
c.Channel.WithoutNamespace.Presence = true
err := c.Validate()
require.NoError(t, err)
}
func TestConfigValidateHistoryTTL(t *testing.T) {
t.Run("in_namespace", func(t *testing.T) {
c := defaultConfig(t)
c.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "name1",
ChannelOptions: configtypes.ChannelOptions{
HistorySize: 10,
HistoryTTL: configtypes.Duration(20 * time.Second),
HistoryMetaTTL: configtypes.Duration(10 * time.Second),
},
},
}
err := c.Validate()
require.ErrorContains(t, err, "history meta ttl")
})
t.Run("on_top_level", func(t *testing.T) {
c := defaultConfig(t)
c.Channel.WithoutNamespace.HistorySize = 10
c.Channel.WithoutNamespace.HistoryTTL = configtypes.Duration(31 * 24 * time.Hour)
err := c.Validate()
require.ErrorContains(t, err, "history meta ttl")
})
t.Run("top_level_non_default_global", func(t *testing.T) {
c := defaultConfig(t)
c.Channel.HistoryMetaTTL = configtypes.Duration(10 * time.Hour)
c.Channel.WithoutNamespace.HistorySize = 10
c.Channel.WithoutNamespace.HistoryTTL = configtypes.Duration(30 * 24 * time.Hour)
err := c.Validate()
require.ErrorContains(t, err, "history meta ttl")
})
}
func TestConfigValidatePersonalSingleConnectionNamespacedFail(t *testing.T) {
c := defaultConfig(t)
c.Channel.Namespaces = []configtypes.ChannelNamespace{}
c.Client.SubscribeToUserPersonalChannel.Enabled = true
c.Client.SubscribeToUserPersonalChannel.SingleConnection = true
c.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace = "public"
err := c.Validate()
require.Error(t, err)
}
func TestConfigValidatePersonalSingleConnectionNamespacedOK(t *testing.T) {
c := defaultConfig(t)
c.Channel.Namespaces = []configtypes.ChannelNamespace{}
c.Client.SubscribeToUserPersonalChannel.Enabled = true
c.Client.SubscribeToUserPersonalChannel.SingleConnection = true
c.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace = "public"
c.Channel.Namespaces = []configtypes.ChannelNamespace{{
Name: "public",
ChannelOptions: configtypes.ChannelOptions{
Presence: true,
},
}}
err := c.Validate()
require.NoError(t, err)
}
func TestConfigValidateMalformedRecoveryTopLevel(t *testing.T) {
c := defaultConfig(t)
c.Channel.WithoutNamespace.ForceRecovery = true
err := c.Validate()
require.Error(t, err)
}
func TestConfigValidateMalformedRecoveryInNamespace(t *testing.T) {
c := defaultConfig(t)
c.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "name",
ChannelOptions: configtypes.ChannelOptions{
ForceRecovery: true,
},
},
}
err := c.Validate()
require.Error(t, err)
}
func TestUserAllowed(t *testing.T) {
rules, err := NewContainer(defaultConfig(t))
require.NoError(t, err)
require.True(t, rules.UserAllowed("channel#1", "1"))
require.True(t, rules.UserAllowed("channel", "1"))
require.False(t, rules.UserAllowed("channel#1", "2"))
require.True(t, rules.UserAllowed("channel#1,2", "1"))
require.True(t, rules.UserAllowed("channel#1,2", "2"))
require.False(t, rules.UserAllowed("channel#1,2", "3"))
}
func TestValidChannelName(t *testing.T) {
c := defaultConfig(t)
c.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "digits",
ChannelOptions: configtypes.ChannelOptions{
ChannelRegex: `^\d+$`,
},
},
{
Name: "plain",
ChannelOptions: configtypes.ChannelOptions{},
},
{
Name: "any",
ChannelOptions: configtypes.ChannelOptions{
ChannelRegex: `^.+$`,
},
},
}
container, err := NewContainer(c)
require.NoError(t, err)
tests := []struct {
name string
channel string
valid bool
}{
{"no regex, ascii channel", "plain:index", true},
{"no regex, non-ascii channel", "plain:индекс", false},
{"regex matches rest, not the whole channel", "digits:42", true},
{"regex does not match rest", "digits:abc", false},
{"regex replaces the ascii check", "any:индекс", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, rest, chOpts, found, err := container.ChannelOptions(tt.channel)
require.NoError(t, err)
require.True(t, found)
valid, err := container.ValidChannelName(tt.channel, rest, chOpts)
require.NoError(t, err)
require.Equal(t, tt.valid, valid)
})
}
}
func TestIsUserLimited(t *testing.T) {
rules, err := NewContainer(defaultConfig(t))
require.NoError(t, err)
require.True(t, rules.IsUserLimited("#12"))
require.True(t, rules.IsUserLimited("test#12"))
config := rules.Config()
config.Channel.UserBoundary = ""
err = rules.Reload(config)
require.NoError(t, err)
require.False(t, rules.IsUserLimited("#12"))
}
func BenchmarkContainer_ChannelOptions(b *testing.B) {
cfg := defaultConfig(b)
const numNamespaces = 128
var channels []string
var namespaces []configtypes.ChannelNamespace
for i := 0; i < numNamespaces; i++ {
namespaces = append(namespaces, configtypes.ChannelNamespace{
Name: "test" + strconv.Itoa(i),
})
channels = append(channels, "test"+strconv.Itoa(i)+":123")
}
cfg.Channel.Namespaces = namespaces
c, _ := NewContainer(cfg)
c.ChannelOptionsCacheTTL = 200 * time.Millisecond
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
i++
ch := channels[i%numNamespaces]
nsName, _, _, ok, _ := c.ChannelOptions(ch)
if !ok {
b.Fatal("ns not found")
}
if !strings.HasPrefix(ch, nsName) {
b.Fatal("wrong ns name: " + nsName)
}
}
})
}
func TestPublicationDataFormatInheritance(t *testing.T) {
t.Run("namespace inherits global format", func(t *testing.T) {
c := defaultConfig(t)
c.Channel.PublicationDataFormat = "json"
c.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "test",
ChannelOptions: configtypes.ChannelOptions{
// No PublicationDataFormat set
},
},
}
container, err := NewContainer(c)
require.NoError(t, err)
// Channel without namespace should use global format
_, _, chOpts, ok, err := container.ChannelOptions("mychannel")
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, "json", chOpts.PublicationDataFormat)
// Namespace without explicit format should inherit global
_, _, chOpts, ok, err = container.ChannelOptions("test:mychannel")
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, "json", chOpts.PublicationDataFormat)
})
t.Run("namespace overrides global format", func(t *testing.T) {
c := defaultConfig(t)
c.Channel.PublicationDataFormat = "json"
c.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "test",
ChannelOptions: configtypes.ChannelOptions{
PublicationDataFormat: "binary",
},
},
}
container, err := NewContainer(c)
require.NoError(t, err)
// Namespace with explicit format should use its own
_, _, chOpts, ok, err := container.ChannelOptions("test:mychannel")
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, "binary", chOpts.PublicationDataFormat)
})
t.Run("no global format set", func(t *testing.T) {
c := defaultConfig(t)
// No global format set
c.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "test",
ChannelOptions: configtypes.ChannelOptions{
// No PublicationDataFormat set
},
},
}
container, err := NewContainer(c)
require.NoError(t, err)
// Should use empty string (default behavior)
_, _, chOpts, ok, err := container.ChannelOptions("test:mychannel")
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, "", chOpts.PublicationDataFormat)
})
}
var testConfig Config
func BenchmarkContainer_Config(b *testing.B) {
cfg := defaultConfig(b)
var namespaces []configtypes.ChannelNamespace
for i := 0; i < 100; i++ {
namespaces = append(namespaces, configtypes.ChannelNamespace{
Name: "test" + strconv.Itoa(i),
})
}
cfg.Channel.Namespaces = namespaces
c, _ := NewContainer(cfg)
c.ChannelOptionsCacheTTL = 200 * time.Millisecond
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
testConfig = c.Config()
if len(testConfig.Channel.Namespaces) != 100 {
b.Fatal("wrong config")
}
}
})
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2013 Kelsey Hightower
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,2 @@
Kelsey Hightower kelsey.hightower@gmail.com github.com/kelseyhightower
Travis Parker travis.parker@gmail.com github.com/teepark

View File

@@ -0,0 +1,5 @@
# envconfig
This is a fork of https://github.com/kelseyhightower/envconfig to make it fit Centrifugo, original license left unchanged.
There are changes to have access to VarInfo and to avoid using defaults for non-zero values.

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2013 Kelsey Hightower. All rights reserved.
// Use of this source code is governed by the MIT License that can be found in
// the LICENSE file.
// Package envconfig implements decoding of environment variables based on a user
// defined specification. A typical use is using environment variables for
// configuration settings.
package envconfig

View File

@@ -0,0 +1,7 @@
//go:build appengine || go1.5
package envconfig
import "os"
var lookupEnv = os.LookupEnv

View File

@@ -0,0 +1,8 @@
//go:build !appengine && !go1.5
// +build !appengine,!go1.5
package envconfig
import "syscall"
var lookupEnv = syscall.Getenv

View File

@@ -0,0 +1,425 @@
// Copyright (c) 2013 Kelsey Hightower. All rights reserved.
// Use of this source code is governed by the MIT License that can be found in
// the LICENSE file.
package envconfig
import (
"encoding"
"errors"
"fmt"
"os"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"time"
)
// ErrInvalidSpecification indicates that a specification is of the wrong type.
var ErrInvalidSpecification = errors.New("specification must be a struct pointer")
var gatherRegexp = regexp.MustCompile("([^A-Z]+|[A-Z]+[^A-Z]+|[A-Z]+)")
var acronymRegexp = regexp.MustCompile("([A-Z]+)([A-Z][^A-Z]+)")
// A ParseError occurs when an environment variable cannot be converted to
// the type required by a struct field during assignment.
type ParseError struct {
KeyName string
FieldName string
TypeName string
Value string
Err error
}
// Decoder has the same semantics as Setter, but takes higher precedence.
// It is provided for historical compatibility.
type Decoder interface {
Decode(value string) error
}
// Setter is implemented by types can self-deserialize values.
// Any type that implements flag.Value also implements Setter.
type Setter interface {
Set(value string) error
}
func (e *ParseError) Error() string {
return fmt.Sprintf("envconfig.Process: assigning %[1]s to %[2]s: converting '%[3]s' to type %[4]s. details: %[5]s", e.KeyName, e.FieldName, e.Value, e.TypeName, e.Err)
}
// VarInfo maintains information about the configuration variable
type VarInfo struct {
Name string
Alt string
Key string
NestedKey string
Field reflect.Value
Tags reflect.StructTag
NonZeroInFile bool
}
// GatherInfo gathers information about the specified struct
func gatherInfo(prefix string, spec interface{}) ([]VarInfo, error) {
s := reflect.ValueOf(spec)
if s.Kind() != reflect.Pointer {
return nil, ErrInvalidSpecification
}
s = s.Elem()
if s.Kind() != reflect.Struct {
return nil, ErrInvalidSpecification
}
typeOfSpec := s.Type()
// over allocate an info array, we will extend if needed later
infos := make([]VarInfo, 0, s.NumField())
for i := 0; i < s.NumField(); i++ {
f := s.Field(i)
ftype := typeOfSpec.Field(i)
if !f.CanSet() || isTrue(ftype.Tag.Get("ignored")) {
continue
}
for f.Kind() == reflect.Pointer {
if f.IsNil() {
if f.Type().Elem().Kind() != reflect.Struct {
// nil pointer to a non-struct: leave it alone
break
}
// nil pointer to struct: create a zero instance
f.Set(reflect.New(f.Type().Elem()))
}
f = f.Elem()
}
name := strings.ToUpper(ftype.Tag.Get("envconfig"))
if name == "" {
name = ftype.Name
}
// Capture information about the config variable
info := VarInfo{
Name: name,
Field: f,
Tags: ftype.Tag,
//Alt: strings.ToUpper(ftype.Tag.Get("envconfig")),
NonZeroInFile: !f.IsZero(),
}
// Default to the field name as the env var name (will be upcased)
info.Key = info.Name
// Best effort to un-pick camel casing as separate words
if isTrue(ftype.Tag.Get("split_words")) {
words := gatherRegexp.FindAllStringSubmatch(ftype.Name, -1)
if len(words) > 0 {
var name []string
for _, words := range words {
if m := acronymRegexp.FindStringSubmatch(words[0]); len(m) == 3 {
name = append(name, m[1], m[2])
} else {
name = append(name, words[0])
}
}
info.Key = strings.Join(name, "_")
}
}
if info.Alt != "" {
info.Key = info.Alt
}
if prefix != "" {
info.Key = fmt.Sprintf("%s_%s", prefix, info.Key)
}
info.Key = strings.ToUpper(info.Key)
// Construct NestedKey: lowercase, without prefix.
info.NestedKey = strings.ToLower(info.Name)
infos = append(infos, info)
if f.Kind() == reflect.Struct {
// honor Decode if present
if decoderFrom(f) == nil && setterFrom(f) == nil && textUnmarshaler(f) == nil && binaryUnmarshaler(f) == nil {
innerPrefix := prefix
if !ftype.Anonymous {
innerPrefix = info.Key
}
embeddedPtr := f.Addr().Interface()
embeddedInfos, err := gatherInfo(innerPrefix, embeddedPtr)
if err != nil {
return nil, err
}
// Update NestedKey for embedded fields.
for i := range embeddedInfos {
if !ftype.Anonymous {
nestedPrefix := strings.ToLower(info.Name)
embeddedInfos[i].NestedKey = nestedPrefix + "." + embeddedInfos[i].NestedKey
}
}
infos = append(infos[:len(infos)-1], embeddedInfos...)
continue
}
}
}
return infos, nil
}
// CheckDisallowed checks that no environment variables with the prefix are set
// that we don't know how or want to parse. This is likely only meaningful with
// a non-empty prefix.
func CheckDisallowed(prefix string, spec interface{}) error {
infos, err := gatherInfo(prefix, spec)
if err != nil {
return err
}
vars := make(map[string]struct{})
for _, info := range infos {
vars[info.Key] = struct{}{}
}
if prefix != "" {
prefix = strings.ToUpper(prefix) + "_"
}
for _, env := range os.Environ() {
if !strings.HasPrefix(env, prefix) {
continue
}
v := strings.SplitN(env, "=", 2)[0]
if _, found := vars[v]; !found {
return fmt.Errorf("unknown environment variable %s", v)
}
}
return nil
}
// Process populates the specified struct based on environment variables
func Process(prefix string, spec interface{}) ([]VarInfo, error) {
return ProcessWithoutNestedKeys(prefix, spec, nil)
}
// ProcessWithoutNestedKeys populates the specified struct based on environment variables
// but excludes fields whose NestedKey is in the excludeKeys list
func ProcessWithoutNestedKeys(prefix string, spec interface{}, excludeKeys []string) ([]VarInfo, error) {
infos, err := gatherInfo(prefix, spec)
for _, info := range infos {
// Skip processing if this key is excluded.
if slices.Contains(excludeKeys, info.NestedKey) {
continue
}
// `os.Getenv` cannot differentiate between an explicitly set empty value
// and an unset value. `os.LookupEnv` is preferred to `syscall.Getenv`,
// but it is only available in go1.5 or newer. We're using Go build tags
// here to use os.LookupEnv for >=go1.5
value, ok := lookupEnv(info.Key)
if !ok && info.Alt != "" {
value, ok = lookupEnv(info.Alt)
}
var defaultIsUsed bool
def := info.Tags.Get("default")
if def != "" && (!ok || value == "") { // Empty values are considered unset.
value = def
defaultIsUsed = true
}
req := info.Tags.Get("required")
if !ok && def == "" {
if isTrue(req) {
key := info.Key
if info.Alt != "" {
key = info.Alt
}
return nil, fmt.Errorf("required key %s missing value", key)
}
continue
}
err = processField(value, info.Field, defaultIsUsed)
if err != nil {
return nil, &ParseError{
KeyName: info.Key,
FieldName: info.Name,
TypeName: info.Field.Type().String(),
Value: value,
Err: err,
}
}
}
return infos, err
}
// MustProcess is the same as Process but panics if an error occurs
func MustProcess(prefix string, spec interface{}) {
if _, err := Process(prefix, spec); err != nil {
panic(err)
}
}
const SliceSep = " "
func processField(value string, field reflect.Value, defaultIsUsed bool) error {
// If the value is default but field is not zero already, we don't want to overwrite it.
if defaultIsUsed && !field.IsZero() {
return nil
}
if value == "" {
return nil
}
typ := field.Type()
decoder := decoderFrom(field)
if decoder != nil {
return decoder.Decode(value)
}
// look for Set method if Decode not defined
setter := setterFrom(field)
if setter != nil {
return setter.Set(value)
}
if t := textUnmarshaler(field); t != nil {
return t.UnmarshalText([]byte(value))
}
if b := binaryUnmarshaler(field); b != nil {
return b.UnmarshalBinary([]byte(value))
}
if typ.Kind() == reflect.Pointer {
typ = typ.Elem()
if field.IsNil() {
field.Set(reflect.New(typ))
}
field = field.Elem()
}
switch typ.Kind() {
case reflect.String:
field.SetString(value)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
var (
val int64
err error
)
if field.Kind() == reflect.Int64 && typ.Name() == "Duration" {
var d time.Duration
d, err = time.ParseDuration(value)
val = int64(d)
} else {
val, err = strconv.ParseInt(value, 0, typ.Bits())
}
if err != nil {
return err
}
field.SetInt(val)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
val, err := strconv.ParseUint(value, 0, typ.Bits())
if err != nil {
return err
}
field.SetUint(val)
case reflect.Bool:
val, err := strconv.ParseBool(value)
if err != nil {
return err
}
field.SetBool(val)
case reflect.Float32, reflect.Float64:
val, err := strconv.ParseFloat(value, typ.Bits())
if err != nil {
return err
}
field.SetFloat(val)
case reflect.Slice:
sl := reflect.MakeSlice(typ, 0, 0)
if typ.Elem().Kind() == reflect.Uint8 {
sl = reflect.ValueOf([]byte(value))
} else if strings.TrimSpace(value) != "" {
vals := strings.Split(value, SliceSep)
sl = reflect.MakeSlice(typ, len(vals), len(vals))
for i, val := range vals {
err := processField(val, sl.Index(i), defaultIsUsed)
if err != nil {
return err
}
}
}
field.Set(sl)
case reflect.Map:
mp := reflect.MakeMap(typ)
if strings.TrimSpace(value) != "" {
pairs := strings.Split(value, ",")
for _, pair := range pairs {
kvpair := strings.Split(pair, ":")
if len(kvpair) != 2 {
return fmt.Errorf("invalid map item: %q", pair)
}
k := reflect.New(typ.Key()).Elem()
err := processField(kvpair[0], k, defaultIsUsed)
if err != nil {
return err
}
v := reflect.New(typ.Elem()).Elem()
err = processField(kvpair[1], v, defaultIsUsed)
if err != nil {
return err
}
mp.SetMapIndex(k, v)
}
}
field.Set(mp)
}
return nil
}
func interfaceFrom(field reflect.Value, fn func(interface{}, *bool)) {
// it may be impossible for a struct field to fail this check
if !field.CanInterface() {
return
}
var ok bool
fn(field.Interface(), &ok)
if !ok && field.CanAddr() {
fn(field.Addr().Interface(), &ok)
}
}
func decoderFrom(field reflect.Value) (d Decoder) {
interfaceFrom(field, func(v interface{}, ok *bool) { d, *ok = v.(Decoder) })
return d
}
func setterFrom(field reflect.Value) (s Setter) {
interfaceFrom(field, func(v interface{}, ok *bool) { s, *ok = v.(Setter) })
return s
}
func textUnmarshaler(field reflect.Value) (t encoding.TextUnmarshaler) {
interfaceFrom(field, func(v interface{}, ok *bool) { t, *ok = v.(encoding.TextUnmarshaler) })
return t
}
func binaryUnmarshaler(field reflect.Value) (b encoding.BinaryUnmarshaler) {
interfaceFrom(field, func(v interface{}, ok *bool) { b, *ok = v.(encoding.BinaryUnmarshaler) })
return b
}
func isTrue(s string) bool {
b, _ := strconv.ParseBool(s)
return b
}

View File

@@ -0,0 +1,68 @@
//go:build go1.8
package envconfig
import (
"errors"
"net/url"
"os"
"testing"
)
type SpecWithURL struct {
UrlValue url.URL
UrlPointer *url.URL
}
func TestParseURL(t *testing.T) {
var s SpecWithURL
os.Clearenv()
_ = os.Setenv("ENV_CONFIG_URLVALUE", "https://github.com/kelseyhightower/envconfig")
_ = os.Setenv("ENV_CONFIG_URLPOINTER", "https://github.com/kelseyhightower/envconfig")
_, err := Process("env_config", &s)
if err != nil {
t.Fatal("unexpected error:", err)
}
u, err := url.Parse("https://github.com/kelseyhightower/envconfig")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s.UrlValue != *u {
t.Errorf("expected %q, got %q", u, s.UrlValue.String())
}
if *s.UrlPointer != *u {
t.Errorf("expected %q, got %q", u, s.UrlPointer)
}
}
func TestParseURLError(t *testing.T) {
var s SpecWithURL
os.Clearenv()
_ = os.Setenv("ENV_CONFIG_URLPOINTER", "http_://foo")
_, err := Process("env_config", &s)
v, ok := err.(*ParseError)
if !ok {
t.Fatalf("expected ParseError, got %T %v", err, err)
}
if v.FieldName != "UrlPointer" {
t.Errorf("expected %s, got %v", "UrlPointer", v.FieldName)
}
expectedUnderlyingError := url.Error{
Op: "parse",
URL: "http_://foo",
Err: errors.New("first path segment in URL cannot contain colon"),
}
if v.Err.Error() != expectedUnderlyingError.Error() {
t.Errorf("expected %q, got %q", expectedUnderlyingError, v.Err)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
ENV_CONFIG_ENABLED=some.embedded.value
ENV_CONFIG_EMBEDDEDPORT=
ENV_CONFIG_MULTIWORDVAR=
ENV_CONFIG_MULTI_WITH_DIFFERENT_ALT=
ENV_CONFIG_EMBEDDED_WITH_ALT=
ENV_CONFIG_DEBUG=
ENV_CONFIG_PORT=
ENV_CONFIG_RATE=
ENV_CONFIG_USER=
ENV_CONFIG_TTL=
ENV_CONFIG_TIMEOUT=
ENV_CONFIG_ADMINUSERS=
ENV_CONFIG_MAGICNUMBERS=
ENV_CONFIG_EMPTYNUMBERS=
ENV_CONFIG_BYTESLICE=
ENV_CONFIG_COLORCODES=
ENV_CONFIG_MULTIWORDVAR=
ENV_CONFIG_MULTI_WORD_VAR_WITH_AUTO_SPLIT=
ENV_CONFIG_MULTI_WORD_ACR_WITH_AUTO_SPLIT=
ENV_CONFIG_SOMEPOINTER=
ENV_CONFIG_SOMEPOINTERWITHDEFAULT=foorbar.is.the.word
ENV_CONFIG_MULTI_WORD_VAR_WITH_ALT=what.alt
ENV_CONFIG_MULTI_WORD_VAR_WITH_LOWER_CASE_ALT=
ENV_CONFIG_SERVICE_HOST=
ENV_CONFIG_DEFAULTVAR=
ENV_CONFIG_REQUIREDVAR=
ENV_CONFIG_BROKER=
ENV_CONFIG_REQUIREDDEFAULT=
ENV_CONFIG_OUTER_INNER=
ENV_CONFIG_OUTER_PROPERTYWITHDEFAULT=
ENV_CONFIG_AFTERNESTED=
ENV_CONFIG_HONOR=
ENV_CONFIG_DATETIME=
ENV_CONFIG_MAPFIELD=
ENV_CONFIG_URLVALUE=
ENV_CONFIG_URLPOINTER=

View File

@@ -0,0 +1,183 @@
This.application.is.configured.via.the.environment..The.following.environment
variables.can.be.used:
ENV_CONFIG_ENABLED
..[description].some.embedded.value
..[type]........True.or.False
..[default].....
..[required]....
ENV_CONFIG_EMBEDDEDPORT
..[description].
..[type]........Integer
..[default].....
..[required]....
ENV_CONFIG_MULTIWORDVAR
..[description].
..[type]........String
..[default].....
..[required]....
ENV_CONFIG_MULTI_WITH_DIFFERENT_ALT
..[description].
..[type]........String
..[default].....
..[required]....
ENV_CONFIG_EMBEDDED_WITH_ALT
..[description].
..[type]........String
..[default].....
..[required]....
ENV_CONFIG_DEBUG
..[description].
..[type]........True.or.False
..[default].....
..[required]....
ENV_CONFIG_PORT
..[description].
..[type]........Integer
..[default].....
..[required]....
ENV_CONFIG_RATE
..[description].
..[type]........Float
..[default].....
..[required]....
ENV_CONFIG_USER
..[description].
..[type]........String
..[default].....
..[required]....
ENV_CONFIG_TTL
..[description].
..[type]........Unsigned.Integer
..[default].....
..[required]....
ENV_CONFIG_TIMEOUT
..[description].
..[type]........Duration
..[default].....
..[required]....
ENV_CONFIG_ADMINUSERS
..[description].
..[type]........Comma-separated.list.of.String
..[default].....
..[required]....
ENV_CONFIG_MAGICNUMBERS
..[description].
..[type]........Comma-separated.list.of.Integer
..[default].....
..[required]....
ENV_CONFIG_EMPTYNUMBERS
..[description].
..[type]........Comma-separated.list.of.Integer
..[default].....
..[required]....
ENV_CONFIG_BYTESLICE
..[description].
..[type]........String
..[default].....
..[required]....
ENV_CONFIG_COLORCODES
..[description].
..[type]........Comma-separated.list.of.String:Integer.pairs
..[default].....
..[required]....
ENV_CONFIG_MULTIWORDVAR
..[description].
..[type]........String
..[default].....
..[required]....
ENV_CONFIG_MULTI_WORD_VAR_WITH_AUTO_SPLIT
..[description].
..[type]........Unsigned.Integer
..[default].....
..[required]....
ENV_CONFIG_MULTI_WORD_ACR_WITH_AUTO_SPLIT
..[description].
..[type]........Unsigned.Integer
..[default].....
..[required]....
ENV_CONFIG_SOMEPOINTER
..[description].
..[type]........String
..[default].....
..[required]....
ENV_CONFIG_SOMEPOINTERWITHDEFAULT
..[description].foorbar.is.the.word
..[type]........String
..[default].....foo2baz
..[required]....
ENV_CONFIG_MULTI_WORD_VAR_WITH_ALT
..[description].what.alt
..[type]........String
..[default].....
..[required]....
ENV_CONFIG_MULTI_WORD_VAR_WITH_LOWER_CASE_ALT
..[description].
..[type]........String
..[default].....
..[required]....
ENV_CONFIG_SERVICE_HOST
..[description].
..[type]........String
..[default].....
..[required]....
ENV_CONFIG_DEFAULTVAR
..[description].
..[type]........String
..[default].....foobar
..[required]....
ENV_CONFIG_REQUIREDVAR
..[description].
..[type]........String
..[default].....
..[required]....true
ENV_CONFIG_BROKER
..[description].
..[type]........String
..[default].....127.0.0.1
..[required]....
ENV_CONFIG_REQUIREDDEFAULT
..[description].
..[type]........String
..[default].....foo2bar
..[required]....true
ENV_CONFIG_OUTER_INNER
..[description].
..[type]........String
..[default].....
..[required]....
ENV_CONFIG_OUTER_PROPERTYWITHDEFAULT
..[description].
..[type]........String
..[default].....fuzzybydefault
..[required]....
ENV_CONFIG_AFTERNESTED
..[description].
..[type]........String
..[default].....
..[required]....
ENV_CONFIG_HONOR
..[description].
..[type]........HonorDecodeInStruct
..[default].....
..[required]....
ENV_CONFIG_DATETIME
..[description].
..[type]........Time
..[default].....
..[required]....
ENV_CONFIG_MAPFIELD
..[description].
..[type]........Comma-separated.list.of.String:String.pairs
..[default].....one:two,three:four
..[required]....
ENV_CONFIG_URLVALUE
..[description].
..[type]........CustomURL
..[default].....
..[required]....
ENV_CONFIG_URLPOINTER
..[description].
..[type]........CustomURL
..[default].....
..[required]....

View File

@@ -0,0 +1,40 @@
This.application.is.configured.via.the.environment..The.following.environment
variables.can.be.used:
KEY..............................................TYPE............................................DEFAULT...............REQUIRED....DESCRIPTION
ENV_CONFIG_ENABLED...............................True.or.False.....................................................................some.embedded.value
ENV_CONFIG_EMBEDDEDPORT..........................Integer...........................................................................
ENV_CONFIG_MULTIWORDVAR..........................String............................................................................
ENV_CONFIG_MULTI_WITH_DIFFERENT_ALT..............String............................................................................
ENV_CONFIG_EMBEDDED_WITH_ALT.....................String............................................................................
ENV_CONFIG_DEBUG.................................True.or.False.....................................................................
ENV_CONFIG_PORT..................................Integer...........................................................................
ENV_CONFIG_RATE..................................Float.............................................................................
ENV_CONFIG_USER..................................String............................................................................
ENV_CONFIG_TTL...................................Unsigned.Integer..................................................................
ENV_CONFIG_TIMEOUT...............................Duration..........................................................................
ENV_CONFIG_ADMINUSERS............................Comma-separated.list.of.String....................................................
ENV_CONFIG_MAGICNUMBERS..........................Comma-separated.list.of.Integer...................................................
ENV_CONFIG_EMPTYNUMBERS..........................Comma-separated.list.of.Integer...................................................
ENV_CONFIG_BYTESLICE.............................String............................................................................
ENV_CONFIG_COLORCODES............................Comma-separated.list.of.String:Integer.pairs......................................
ENV_CONFIG_MULTIWORDVAR..........................String............................................................................
ENV_CONFIG_MULTI_WORD_VAR_WITH_AUTO_SPLIT........Unsigned.Integer..................................................................
ENV_CONFIG_MULTI_WORD_ACR_WITH_AUTO_SPLIT........Unsigned.Integer..................................................................
ENV_CONFIG_SOMEPOINTER...........................String............................................................................
ENV_CONFIG_SOMEPOINTERWITHDEFAULT................String..........................................foo2baz...........................foorbar.is.the.word
ENV_CONFIG_MULTI_WORD_VAR_WITH_ALT...............String............................................................................what.alt
ENV_CONFIG_MULTI_WORD_VAR_WITH_LOWER_CASE_ALT....String............................................................................
ENV_CONFIG_SERVICE_HOST..........................String............................................................................
ENV_CONFIG_DEFAULTVAR............................String..........................................foobar............................
ENV_CONFIG_REQUIREDVAR...........................String................................................................true........
ENV_CONFIG_BROKER................................String..........................................127.0.0.1.........................
ENV_CONFIG_REQUIREDDEFAULT.......................String..........................................foo2bar...............true........
ENV_CONFIG_OUTER_INNER...........................String............................................................................
ENV_CONFIG_OUTER_PROPERTYWITHDEFAULT.............String..........................................fuzzybydefault....................
ENV_CONFIG_AFTERNESTED...........................String............................................................................
ENV_CONFIG_HONOR.................................HonorDecodeInStruct...............................................................
ENV_CONFIG_DATETIME..............................Time..............................................................................
ENV_CONFIG_MAPFIELD..............................Comma-separated.list.of.String:String.pairs.....one:two,three:four................
ENV_CONFIG_URLVALUE..............................CustomURL.........................................................................
ENV_CONFIG_URLPOINTER............................CustomURL.........................................................................

View File

@@ -0,0 +1,36 @@
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}
{.Key}

31
internal/config/format.go Normal file
View File

@@ -0,0 +1,31 @@
package config
import (
"errors"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/tools"
)
// ValidatePublicationData validates publication data according to the specified format.
// Returns an error if the data doesn't match the format requirements.
func ValidatePublicationData(data []byte, format string) error {
switch format {
case configtypes.PublicationDataFormatJSON:
if !tools.IsValidJSON(data) {
return errors.New("data is not valid JSON")
}
case configtypes.PublicationDataFormatJSONObject:
if !tools.IsValidJSONObject(data) {
return errors.New("data is not valid JSON object")
}
case configtypes.PublicationDataFormatBinary:
// Binary format allows empty data, no validation needed
default:
// Default behavior: reject empty data
if len(data) == 0 {
return errors.New("data required")
}
}
return nil
}

View File

@@ -0,0 +1,159 @@
package config
import (
"testing"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/stretchr/testify/require"
)
func TestValidatePublicationData(t *testing.T) {
tests := []struct {
name string
data []byte
format string
wantErr bool
errMsg string
}{
// Default format tests
{
name: "default format - non-empty data allowed",
data: []byte("any data"),
format: "",
wantErr: false,
},
{
name: "default format - empty data rejected",
data: []byte{},
format: "",
wantErr: true,
errMsg: "data required",
},
// JSON format tests
{
name: "json format - valid json object",
data: []byte(`{"key": "value"}`),
format: configtypes.PublicationDataFormatJSON,
wantErr: false,
},
{
name: "json format - valid json array",
data: []byte(`[1, 2, 3]`),
format: configtypes.PublicationDataFormatJSON,
wantErr: false,
},
{
name: "json format - valid json primitives",
data: []byte(`"string"`),
format: configtypes.PublicationDataFormatJSON,
wantErr: false,
},
{
name: "json format - valid json null",
data: []byte(`null`),
format: configtypes.PublicationDataFormatJSON,
wantErr: false,
},
{
name: "json format - invalid json",
data: []byte(`not valid json`),
format: configtypes.PublicationDataFormatJSON,
wantErr: true,
errMsg: "data is not valid JSON",
},
{
name: "json format - empty data",
data: []byte{},
format: configtypes.PublicationDataFormatJSON,
wantErr: true,
errMsg: "data is not valid JSON",
},
// JSON object format tests
{
name: "json_object format - valid json object",
data: []byte(`{"key": "value"}`),
format: configtypes.PublicationDataFormatJSONObject,
wantErr: false,
},
{
name: "json_object format - valid json object with whitespace",
data: []byte(` {"key": "value"} `),
format: configtypes.PublicationDataFormatJSONObject,
wantErr: false,
},
{
name: "json_object format - json array rejected",
data: []byte(`[1, 2, 3]`),
format: configtypes.PublicationDataFormatJSONObject,
wantErr: true,
errMsg: "data is not valid JSON object",
},
{
name: "json_object format - json string rejected",
data: []byte(`"string"`),
format: configtypes.PublicationDataFormatJSONObject,
wantErr: true,
errMsg: "data is not valid JSON object",
},
{
name: "json_object format - json null rejected",
data: []byte(`null`),
format: configtypes.PublicationDataFormatJSONObject,
wantErr: true,
errMsg: "data is not valid JSON object",
},
{
name: "json_object format - json number rejected",
data: []byte(`42`),
format: configtypes.PublicationDataFormatJSONObject,
wantErr: true,
errMsg: "data is not valid JSON object",
},
{
name: "json_object format - invalid json",
data: []byte(`not valid json`),
format: configtypes.PublicationDataFormatJSONObject,
wantErr: true,
errMsg: "data is not valid JSON object",
},
{
name: "json_object format - empty data",
data: []byte{},
format: configtypes.PublicationDataFormatJSONObject,
wantErr: true,
errMsg: "data is not valid JSON object",
},
// Binary format tests
{
name: "binary format - binary data allowed",
data: []byte{0x01, 0x02, 0x03},
format: configtypes.PublicationDataFormatBinary,
wantErr: false,
},
{
name: "binary format - empty data allowed",
data: []byte{},
format: configtypes.PublicationDataFormatBinary,
wantErr: false,
},
{
name: "binary format - text data allowed",
data: []byte("any text"),
format: configtypes.PublicationDataFormatBinary,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePublicationData(tt.data, tt.format)
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), tt.errMsg)
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -0,0 +1,6 @@
package config
func applyConfigMigrations(cfg Config) (Config, []string) {
var warnings []string
return cfg, warnings
}

View File

@@ -0,0 +1,240 @@
package config
import (
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/stretchr/testify/require"
)
func sharedPollDefaultProxy() configtypes.Proxy {
return configtypes.Proxy{
Endpoint: "http://localhost:3001/refresh",
Timeout: configtypes.Duration(5 * time.Second),
}
}
func TestSharedPollConfig_Validation_MissingSecret(t *testing.T) {
cfg := DefaultConfig()
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "poll",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "shared_poll",
},
},
}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "hmac_secret_key")
}
func TestSharedPollConfig_Validation_WithSecret(t *testing.T) {
cfg := DefaultConfig()
cfg.SharedPoll.HMACSecretKey = "my-secret"
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "poll",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "shared_poll",
},
},
}
err := cfg.Validate()
require.NoError(t, err)
}
func TestSharedPollConfig_Validation_InvalidProxyName(t *testing.T) {
cfg := DefaultConfig()
cfg.SharedPoll.HMACSecretKey = "my-secret"
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "poll",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "shared_poll",
SharedPoll: configtypes.SharedPollConfig{
ProxyName: "nonexistent_proxy",
},
},
},
}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "shared poll proxy")
require.Contains(t, err.Error(), "nonexistent_proxy")
}
func TestSharedPollConfig_Validation_ValidProxyName(t *testing.T) {
cfg := DefaultConfig()
cfg.SharedPoll.HMACSecretKey = "my-secret"
cfg.Proxies = []configtypes.NamedProxy{
{
Name: "poll_backend",
Proxy: configtypes.Proxy{
Endpoint: "http://localhost:3001/refresh",
Timeout: configtypes.Duration(5 * time.Second),
},
},
}
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "poll",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "shared_poll",
SharedPoll: configtypes.SharedPollConfig{
ProxyName: "poll_backend",
},
},
},
}
err := cfg.Validate()
require.NoError(t, err)
}
func TestSharedPollConfig_SubscriptionTypeValid(t *testing.T) {
cfg := DefaultConfig()
cfg.SharedPoll.HMACSecretKey = "secret"
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "poll",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "shared_poll",
},
},
}
err := cfg.Validate()
require.NoError(t, err)
}
func TestSharedPollConfig_UnknownSubscriptionType(t *testing.T) {
cfg := DefaultConfig()
cfg.Channel.WithoutNamespace.SubscriptionType = "unknown_type"
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "unknown subscription_type")
}
func TestSharedPollConfig_WithoutNamespaceValidation(t *testing.T) {
cfg := DefaultConfig()
cfg.SharedPoll.HMACSecretKey = "secret"
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
cfg.Channel.WithoutNamespace.SubscriptionType = "shared_poll"
err := cfg.Validate()
require.NoError(t, err)
}
func TestSharedPollConfig_WithoutNamespaceMissingSecret(t *testing.T) {
cfg := DefaultConfig()
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
cfg.Channel.WithoutNamespace.SubscriptionType = "shared_poll"
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "hmac_secret_key")
}
func TestSharedPollConfig_VersionlessPublishEnabled(t *testing.T) {
t.Run("empty_mode_publish_enabled_rejected", func(t *testing.T) {
cfg := DefaultConfig()
cfg.SharedPoll.HMACSecretKey = "secret"
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "poll",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "shared_poll",
SharedPoll: configtypes.SharedPollConfig{
Mode: "",
PublishEnabled: true,
},
},
},
}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "publish_enabled is incompatible with versionless")
})
t.Run("versionless_mode_publish_enabled_rejected", func(t *testing.T) {
cfg := DefaultConfig()
cfg.SharedPoll.HMACSecretKey = "secret"
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "poll",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "shared_poll",
SharedPoll: configtypes.SharedPollConfig{
Mode: "versionless",
PublishEnabled: true,
},
},
},
}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "publish_enabled is incompatible with versionless")
})
t.Run("full_mode_publish_enabled_accepted", func(t *testing.T) {
cfg := DefaultConfig()
cfg.SharedPoll.HMACSecretKey = "secret"
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "poll",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "shared_poll",
SharedPoll: configtypes.SharedPollConfig{
Mode: "versioned",
PublishEnabled: true,
},
},
},
}
err := cfg.Validate()
require.NoError(t, err)
})
t.Run("versionless_no_publish_accepted", func(t *testing.T) {
cfg := DefaultConfig()
cfg.SharedPoll.HMACSecretKey = "secret"
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "poll",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "shared_poll",
SharedPoll: configtypes.SharedPollConfig{
Mode: "versionless",
},
},
},
}
err := cfg.Validate()
require.NoError(t, err)
})
t.Run("versionless_string_accepted", func(t *testing.T) {
cfg := DefaultConfig()
cfg.SharedPoll.HMACSecretKey = "secret"
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "poll",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "shared_poll",
SharedPoll: configtypes.SharedPollConfig{
Mode: "versionless",
},
},
},
}
err := cfg.Validate()
require.NoError(t, err)
})
}

80
internal/config/testdata/config.json vendored Normal file
View File

@@ -0,0 +1,80 @@
{
"http_server": {
"tls": {
"enabled": true,
"server_ca_pem": "-----BEGIN CERTIFICATE-----\nMIIEbjCCAtagAwIBAgIRAN1ZJEYl5ZNIOHsbJizQpucwDQYJKoZIhvcNAQELBQAw\ngZ8xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE6MDgGA1UECwwxZnpA\nTWFjQm9vay1Qcm8tQWxleGFuZGVyLmxvY2FsIChBbGV4YW5kZXIgRW1lbGluKTFB\nMD8GA1UEAww4bWtjZXJ0IGZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAo\nQWxleGFuZGVyIEVtZWxpbikwHhcNMjIwNjE2MDYxOTM0WhcNMjQwOTE2MDYxOTM0\nWjBlMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxOjA4\nBgNVBAsMMWZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAoQWxleGFuZGVy\nIEVtZWxpbikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDLCNVIle5k\nlfRtzjHe9sEo8zU9pqXfK9fxc2PZqfd6HVDVWyrOHNv9zWV8awEEgwX2kg+sY4ch\nuKmNdD19UWxLovCMkA92gKhzJoPPBMlVRtSA9QWNw4cXXB25KErPPyBXyyFA13X/\n6N408I26Aj6ewA0WLISkNgiCddUo31FygTNH4yWXF+F+lol0EJhG+K3E8diYub4P\n1Ul417sQ/1FxcoGo43fGl8j4y6wCnBQkSNaQCr1vvNEzdmiIYF02a51Efdb3PrSu\n90nJJBbFQxNhpcl98tLRF5t3wZJ+R2Xy4xPUZYwNNWTdICqW7a4bfD4foByp85kr\nu44kw7laXghhAgMBAAGjXjBcMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr\nBgEFBQcDATAfBgNVHSMEGDAWgBSMh55IrbevJTB4kiFUXsarEAIjXjAUBgNVHREE\nDTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggGBAG9yTMOybS6ike/UoIrj\nxLE3a9nPuFdt9anS0XgYicCYNFLc5H6MUXubsqBz30zigFbNP/FR2DKvIP+1cySP\nDKqnimTdxZWjzT9d0YHEYcD971yk/whXmKOcla2VmYMuPmUr6M3BmUmYcoWve/ML\nnc8qKJ+CsM80zxFSRbqCVqgPfNDzPHqGbJmOn0KbLPWzkUsIbii/O4IjqycJiDMS\nCyuat2Q8TYGiRhDJnouD/semDtqaIGGT77/5QLoEhFRwRKbOfgTT0hjLgTbeKPrx\nQKARxjVC/QF59nhdf+je/BgrF7jfR1UuCSxwl0xg2Ub2JB5A77efWEoQh2fuSgZk\nmVTZqDnfGvfYcGE9oiAMl21DimEAdYFSAUTtVI6T0S8BagN3jD+FLV7+TJgPiyIO\nLz9gcDP1Zn3jIp4Vy2HawWt+8rta351L70ie9Sk6Cx5fV0slvTFteWYdm26BuKbp\nNF7OqlGSRzM2iEVaMFLqnrRwDF4bR7qwGukppEXPrsAq2Q==\n-----END CERTIFICATE-----"
}
},
"engine": {
"type": "redis",
"redis": {
"address": "redis:6379",
"presence_ttl": "30s"
}
},
"client": {
"allowed_origins": ["http://localhost:3000"],
"token": {
"jwks_public_endpoint": "https://example.com/jwks"
},
"ping_interval": "12s",
"proxy": {
"connect": {
"http": {
"static_headers": {
"X": "y"
}
}
},
"refresh": {
"http": {
"static_headers": [
{
"key": "X",
"value": "y"
}
]
}
}
}
},
"channel": {
"without_namespace": {
"presence": true
},
"namespaces": [
{
"name": "ns1",
"presence": true
},
{
"name": "ns2",
"presence": false
}
]
},
"proxies": [
{
"name": "ppp"
}
],
"consumers": [
{
"name": "kafka",
"enabled": true,
"type": "kafka",
"kafka": {
"brokers": ["localhost:9092"],
"topics": ["postgres.public.chat_cdc"],
"consumer_group": "centrifugo",
"tls": {
"enabled": true,
"cert_pem": "xxx"
}
}
}
],
"websocket": {
"write_timeout": "2s"
}
}

84
internal/config/testdata/config.toml vendored Normal file
View File

@@ -0,0 +1,84 @@
[http_server]
[http_server.tls]
enabled = true
server_ca_pem = """
-----BEGIN CERTIFICATE-----
MIIEbjCCAtagAwIBAgIRAN1ZJEYl5ZNIOHsbJizQpucwDQYJKoZIhvcNAQELBQAw
gZ8xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE6MDgGA1UECwwxZnpA
TWFjQm9vay1Qcm8tQWxleGFuZGVyLmxvY2FsIChBbGV4YW5kZXIgRW1lbGluKTFB
MD8GA1UEAww4bWtjZXJ0IGZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAo
QWxleGFuZGVyIEVtZWxpbikwHhcNMjIwNjE2MDYxOTM0WhcNMjQwOTE2MDYxOTM0
WjBlMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxOjA4
BgNVBAsMMWZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAoQWxleGFuZGVy
IEVtZWxpbikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDLCNVIle5k
lfRtzjHe9sEo8zU9pqXfK9fxc2PZqfd6HVDVWyrOHNv9zWV8awEEgwX2kg+sY4ch
uKmNdD19UWxLovCMkA92gKhzJoPPBMlVRtSA9QWNw4cXXB25KErPPyBXyyFA13X/
6N408I26Aj6ewA0WLISkNgiCddUo31FygTNH4yWXF+F+lol0EJhG+K3E8diYub4P
1Ul417sQ/1FxcoGo43fGl8j4y6wCnBQkSNaQCr1vvNEzdmiIYF02a51Efdb3PrSu
90nJJBbFQxNhpcl98tLRF5t3wZJ+R2Xy4xPUZYwNNWTdICqW7a4bfD4foByp85kr
u44kw7laXghhAgMBAAGjXjBcMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr
BgEFBQcDATAfBgNVHSMEGDAWgBSMh55IrbevJTB4kiFUXsarEAIjXjAUBgNVHREE
DTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggGBAG9yTMOybS6ike/UoIrj
xLE3a9nPuFdt9anS0XgYicCYNFLc5H6MUXubsqBz30zigFbNP/FR2DKvIP+1cySP
DKqnimTdxZWjzT9d0YHEYcD971yk/whXmKOcla2VmYMuPmUr6M3BmUmYcoWve/ML
nc8qKJ+CsM80zxFSRbqCVqgPfNDzPHqGbJmOn0KbLPWzkUsIbii/O4IjqycJiDMS
Cyuat2Q8TYGiRhDJnouD/semDtqaIGGT77/5QLoEhFRwRKbOfgTT0hjLgTbeKPrx
QKARxjVC/QF59nhdf+je/BgrF7jfR1UuCSxwl0xg2Ub2JB5A77efWEoQh2fuSgZk
mVTZqDnfGvfYcGE9oiAMl21DimEAdYFSAUTtVI6T0S8BagN3jD+FLV7+TJgPiyIO
Lz9gcDP1Zn3jIp4Vy2HawWt+8rta351L70ie9Sk6Cx5fV0slvTFteWYdm26BuKbp
NF7OqlGSRzM2iEVaMFLqnrRwDF4bR7qwGukppEXPrsAq2Q==
-----END CERTIFICATE-----
"""
[engine]
type = "redis"
[engine.redis]
address = "redis:6379"
presence_ttl = "30s"
[client]
allowed_origins = [ "http://localhost:3000" ]
ping_interval = "12s"
[client.token]
jwks_public_endpoint = "https://example.com/jwks"
[client.proxy.connect.http.static_headers]
x = "y"
[[client.proxy.refresh.http.static_headers]]
key = "X"
value = "y"
[channel.without_namespace]
presence = true
[[channel.namespaces]]
name = "ns1"
presence = true
[[channel.namespaces]]
name = "ns2"
presence = false
[[proxies]]
name = "ppp"
[[consumers]]
name = "kafka"
enabled = true
type = "kafka"
[consumers.kafka]
brokers = [ "localhost:9092" ]
topics = [ "postgres.public.chat_cdc" ]
consumer_group = "centrifugo"
[consumers.kafka.tls]
enabled = true
cert_pem = "xxx"
[websocket]
write_timeout = "2s"

77
internal/config/testdata/config.yaml vendored Normal file
View File

@@ -0,0 +1,77 @@
---
http_server:
tls:
enabled: true
server_ca_pem: |
-----BEGIN CERTIFICATE-----
MIIEbjCCAtagAwIBAgIRAN1ZJEYl5ZNIOHsbJizQpucwDQYJKoZIhvcNAQELBQAw
gZ8xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE6MDgGA1UECwwxZnpA
TWFjQm9vay1Qcm8tQWxleGFuZGVyLmxvY2FsIChBbGV4YW5kZXIgRW1lbGluKTFB
MD8GA1UEAww4bWtjZXJ0IGZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAo
QWxleGFuZGVyIEVtZWxpbikwHhcNMjIwNjE2MDYxOTM0WhcNMjQwOTE2MDYxOTM0
WjBlMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxOjA4
BgNVBAsMMWZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAoQWxleGFuZGVy
IEVtZWxpbikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDLCNVIle5k
lfRtzjHe9sEo8zU9pqXfK9fxc2PZqfd6HVDVWyrOHNv9zWV8awEEgwX2kg+sY4ch
uKmNdD19UWxLovCMkA92gKhzJoPPBMlVRtSA9QWNw4cXXB25KErPPyBXyyFA13X/
6N408I26Aj6ewA0WLISkNgiCddUo31FygTNH4yWXF+F+lol0EJhG+K3E8diYub4P
1Ul417sQ/1FxcoGo43fGl8j4y6wCnBQkSNaQCr1vvNEzdmiIYF02a51Efdb3PrSu
90nJJBbFQxNhpcl98tLRF5t3wZJ+R2Xy4xPUZYwNNWTdICqW7a4bfD4foByp85kr
u44kw7laXghhAgMBAAGjXjBcMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr
BgEFBQcDATAfBgNVHSMEGDAWgBSMh55IrbevJTB4kiFUXsarEAIjXjAUBgNVHREE
DTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggGBAG9yTMOybS6ike/UoIrj
xLE3a9nPuFdt9anS0XgYicCYNFLc5H6MUXubsqBz30zigFbNP/FR2DKvIP+1cySP
DKqnimTdxZWjzT9d0YHEYcD971yk/whXmKOcla2VmYMuPmUr6M3BmUmYcoWve/ML
nc8qKJ+CsM80zxFSRbqCVqgPfNDzPHqGbJmOn0KbLPWzkUsIbii/O4IjqycJiDMS
Cyuat2Q8TYGiRhDJnouD/semDtqaIGGT77/5QLoEhFRwRKbOfgTT0hjLgTbeKPrx
QKARxjVC/QF59nhdf+je/BgrF7jfR1UuCSxwl0xg2Ub2JB5A77efWEoQh2fuSgZk
mVTZqDnfGvfYcGE9oiAMl21DimEAdYFSAUTtVI6T0S8BagN3jD+FLV7+TJgPiyIO
Lz9gcDP1Zn3jIp4Vy2HawWt+8rta351L70ie9Sk6Cx5fV0slvTFteWYdm26BuKbp
NF7OqlGSRzM2iEVaMFLqnrRwDF4bR7qwGukppEXPrsAq2Q==
-----END CERTIFICATE-----
engine:
type: redis
redis:
address: redis:6379
presence_ttl: 30s
client:
allowed_origins:
- http://localhost:3000
token:
jwks_public_endpoint: https://example.com/jwks
ping_interval: 12s
proxy:
connect:
http:
static_headers:
x: "y"
refresh:
http:
static_headers:
- key: X
value: y
channel:
without_namespace:
presence: true
namespaces:
- name: ns1
presence: true
- name: ns2
presence: false
proxies:
- name: ppp
consumers:
- name: kafka
enabled: true
type: kafka
kafka:
brokers:
- localhost:9092
topics:
- postgres.public.chat_cdc
consumer_group: centrifugo
tls:
enabled: true
cert_pem: xxx
websocket:
write_timeout: 2s

606
internal/config/validate.go Normal file
View File

@@ -0,0 +1,606 @@
package config
import (
"errors"
"fmt"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifuge"
)
var knownBrokers = []string{"memory", "nats", "redis", "redisnats", "postgres"}
// Validate validates config and returns error if problems found.
func (c Config) Validate() error {
if c.HTTP.H2CExternal && (c.HTTP.InternalPort == "" || strconv.Itoa(c.HTTP.Port) == c.HTTP.InternalPort) {
return fmt.Errorf("external_h2c requires custom separate internal_port to be configured")
}
if c.HTTP.TLS.Enabled && c.HTTP.H2CExternal {
return fmt.Errorf("external_h2c cannot be used together with enabled TLS for external HTTP server")
}
if !slices.Contains(knownBrokers, c.Broker.Type) {
return fmt.Errorf("unknown broker: %s", c.Broker.Type)
}
if err := validateTokens(c); err != nil {
return err
}
if c.Client.Proxy.Connect.Enabled {
if err := validateProxy("default", c.Client.Proxy.Connect.Proxy); err != nil {
return fmt.Errorf("in client.proxy.connect: %v", err)
}
}
if c.Client.Proxy.Refresh.Enabled {
if err := validateProxy("default", c.Client.Proxy.Refresh.Proxy); err != nil {
return fmt.Errorf("in client.proxy.refresh: %v", err)
}
}
if err := validateSecondPrecisionDuration(c.Channel.HistoryMetaTTL); err != nil {
return fmt.Errorf("in channel.history_meta_ttl: %v", err)
}
if !slices.Contains([]string{"", configtypes.PublicationDataFormatJSON, configtypes.PublicationDataFormatJSONObject, configtypes.PublicationDataFormatBinary}, c.Channel.PublicationDataFormat) {
return fmt.Errorf("unknown channel.publication_data_format: \"%s\"", c.Channel.PublicationDataFormat)
}
if err := validateCodeToUniDisconnectTransforms(c.Client.ConnectCodeToUnidirectionalDisconnect.Transforms); err != nil {
return fmt.Errorf("in client.connect_code_to_unidirectional_disconnect: %v", err)
}
var proxyNames []string
for _, p := range c.Proxies {
if slices.Contains(proxyNames, p.Name) {
return fmt.Errorf("duplicate proxy name in proxies: %s", p.Name)
}
if err := validateProxy(p.Name, p.Proxy); err != nil {
return fmt.Errorf("in proxy %s: %v", p.Name, err)
}
proxyNames = append(proxyNames, p.Name)
}
if slices.Contains(proxyNames, DefaultProxyName) {
return fmt.Errorf("proxy name %s is reserved", DefaultProxyName)
}
proxyNames = append(proxyNames, DefaultProxyName) // channel options can use default proxy name.
if err := validateChannelOptions(c.Channel.WithoutNamespace, c.Channel.HistoryMetaTTL, proxyNames, c); err != nil {
return fmt.Errorf("in channel.without_namespace: %v", err)
}
if err := validateRpcOptions(c.RPC.WithoutNamespace, proxyNames); err != nil {
return fmt.Errorf("in rpc.without_namespace: %v", err)
}
usePersonalChannel := c.Client.SubscribeToUserPersonalChannel.Enabled
personalChannelNamespace := c.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace
personalSingleConnection := c.Client.SubscribeToUserPersonalChannel.SingleConnection
var validPersonalChannelNamespace bool
if !usePersonalChannel || personalChannelNamespace == "" {
validPersonalChannelNamespace = true
if personalSingleConnection && !c.Channel.WithoutNamespace.Presence {
return fmt.Errorf("presence must be enabled on top level to maintain single connection")
}
}
nss := make([]string, 0, len(c.Channel.Namespaces))
for _, n := range c.Channel.Namespaces {
if slices.Contains(nss, n.Name) {
return fmt.Errorf("namespace name must be unique: %s", n.Name)
}
if err := validateNamespace(n, c.Channel.HistoryMetaTTL, proxyNames, c); err != nil {
return fmt.Errorf("namespace %s: %v", n.Name, err)
}
if n.Name == personalChannelNamespace {
validPersonalChannelNamespace = true
if personalSingleConnection && !n.Presence {
return fmt.Errorf("presence must be enabled for namespace %s to maintain single connection", n.Name)
}
}
nss = append(nss, n.Name)
}
if !validPersonalChannelNamespace {
return fmt.Errorf("namespace for user personal channel not found: %s", personalChannelNamespace)
}
// Validate that map presence channel prefix references point to existing namespaces with map type.
boundary := c.Channel.NamespaceBoundary
if boundary == "" {
boundary = ":"
}
for _, n := range c.Channel.Namespaces {
if n.MapClientsPresenceChannelPrefix != "" {
targetNs := strings.SplitN(n.MapClientsPresenceChannelPrefix+"x", boundary, 2)[0]
if !slices.Contains(nss, targetNs) {
return fmt.Errorf("namespace %s: map_clients_presence_channel_prefix %q resolves to namespace %q which does not exist", n.Name, n.MapClientsPresenceChannelPrefix, targetNs)
}
}
if n.MapUsersPresenceChannelPrefix != "" {
targetNs := strings.SplitN(n.MapUsersPresenceChannelPrefix+"x", boundary, 2)[0]
if !slices.Contains(nss, targetNs) {
return fmt.Errorf("namespace %s: map_users_presence_channel_prefix %q resolves to namespace %q which does not exist", n.Name, n.MapUsersPresenceChannelPrefix, targetNs)
}
}
}
rpcNss := make([]string, 0, len(c.RPC.Namespaces))
for _, n := range c.RPC.Namespaces {
if slices.Contains(rpcNss, n.Name) {
return fmt.Errorf("rpc namespace name must be unique: %s", n.Name)
}
if err := validateRpcNamespace(n, proxyNames); err != nil {
return fmt.Errorf("rpc namespace %s: %v", n.Name, err)
}
rpcNss = append(rpcNss, n.Name)
}
var consumerNames []string
for _, config := range c.Consumers {
if !consumerNameRe.Match([]byte(config.Name)) {
return fmt.Errorf("invalid consumer name: %s, must match %s regular expression", config.Name, consumerNamePattern)
}
if slices.Contains(consumerNames, config.Name) {
return fmt.Errorf("invalid consumer name: %s, must be unique", config.Name)
}
if !slices.Contains(configtypes.KnownConsumerTypes, config.Type) {
return fmt.Errorf("unknown consumer type: %s", config.Type)
}
if config.Enabled {
var err error
switch config.Type {
case configtypes.ConsumerTypeKafka:
err = config.Kafka.Validate()
case configtypes.ConsumerTypePostgres:
err = config.Postgres.Validate()
case configtypes.ConsumerTypeGooglePubSub:
err = config.GooglePubSub.Validate()
case configtypes.ConsumerTypeRedisStream:
err = config.RedisStream.Validate()
case configtypes.ConsumerTypeNatsJetStream:
err = config.NatsJetStream.Validate()
case configtypes.ConsumerTypeAwsSqs:
err = config.AwsSqs.Validate()
case configtypes.ConsumerTypeAzureServiceBus:
err = config.AzureServiceBus.Validate()
default:
}
if err != nil {
return fmt.Errorf("in consumer %s (%s): %w", config.Name, config.Type, err)
}
}
consumerNames = append(consumerNames, config.Name)
}
if err := validateConnectCodeTransforms(c.UniSSE.ConnectCodeToHTTPResponse.Transforms); err != nil {
return fmt.Errorf("in uni_sse.connect_code_to_http_response.transforms: %v", err)
}
if err := validateConnectCodeTransforms(c.UniHTTPStream.ConnectCodeToHTTPResponse.Transforms); err != nil {
return fmt.Errorf("in uni_http_stream.connect_code_to_http_response.transforms: %v", err)
}
// Map broker validation.
var knownMapBrokers = []string{"memory", "redis", "postgres"}
if !slices.Contains(knownMapBrokers, c.MapBroker.Type) {
return fmt.Errorf("unknown map broker type: %s", c.MapBroker.Type)
}
return nil
}
var namePattern = "^[-a-zA-Z0-9_.]{2,}$"
var nameRe = regexp.MustCompile(namePattern)
func validateNamespace(ns configtypes.ChannelNamespace, globalHistoryMetaTTL configtypes.Duration, proxyNames []string, cfg Config) error {
name := ns.Name
match := nameRe.MatchString(name)
if !match {
return fmt.Errorf("invalid namespace name – %s (must match %s regular expression)", name, namePattern)
}
if err := validateChannelOptions(ns.ChannelOptions, globalHistoryMetaTTL, proxyNames, cfg); err != nil {
return err
}
return nil
}
func validateRpcNamespace(ns configtypes.RpcNamespace, rpcProxyNames []string) error {
name := ns.Name
match := nameRe.MatchString(name)
if !match {
return fmt.Errorf("invalid rpc namespace name – %s (must match %s regular expression)", name, namePattern)
}
if err := validateRpcOptions(ns.RpcOptions, rpcProxyNames); err != nil {
return err
}
return nil
}
func validateChannelOptions(c configtypes.ChannelOptions, globalHistoryMetaTTL configtypes.Duration, proxyNames []string, cfg Config) error {
if err := validateSecondPrecisionDuration(c.HistoryTTL); err != nil {
return fmt.Errorf("in history_ttl: %v", err)
}
if err := validateSecondPrecisionDuration(c.HistoryMetaTTL); err != nil {
return fmt.Errorf("in history_meta_ttl: %v", err)
}
if (c.HistorySize != 0 && c.HistoryTTL == 0) || (c.HistorySize == 0 && c.HistoryTTL != 0) {
return errors.New("both history size and history ttl required for history")
}
historyMetaTTL := globalHistoryMetaTTL
if c.HistoryMetaTTL != 0 {
historyMetaTTL = c.HistoryMetaTTL
}
if historyMetaTTL < c.HistoryTTL {
return fmt.Errorf("history ttl (%s) can not be greater than history meta ttl (%s)", c.HistoryTTL, historyMetaTTL)
}
if c.ForceRecovery && (c.HistorySize == 0 || c.HistoryTTL == 0) {
return errors.New("both history size and history ttl required for recovery")
}
if c.AutoCacheRecover && (!c.ForceRecovery || c.ForceRecoveryMode != "cache") {
return errors.New("auto_cache_recover requires force_recovery and force_recovery_mode set to cache")
}
if c.ChannelRegex != "" {
if _, err := regexp.Compile(c.ChannelRegex); err != nil {
return fmt.Errorf("invalid channel regex %s: %w", c.ChannelRegex, err)
}
}
if (c.SubscribeStreamProxyEnabled) && (c.SubscribeProxyEnabled || c.PublishProxyEnabled || c.SubRefreshProxyEnabled) {
return fmt.Errorf("can't use subscribe stream proxy together with subscribe, publish or sub refresh proxies")
}
if len(c.AllowedDeltaTypes) > 0 {
for _, dt := range c.AllowedDeltaTypes {
if !slices.Contains([]centrifuge.DeltaType{centrifuge.DeltaTypeFossil}, dt) {
return fmt.Errorf("unknown allowed delta type: \"%s\"", dt)
}
}
}
if !slices.Contains([]string{"", "stream", "cache"}, c.ForceRecoveryMode) {
return fmt.Errorf("unknown recovery mode: \"%s\"", c.ForceRecoveryMode)
}
if !slices.Contains([]string{"", configtypes.PublicationDataFormatJSON, configtypes.PublicationDataFormatJSONObject, configtypes.PublicationDataFormatBinary}, c.PublicationDataFormat) {
return fmt.Errorf("unknown publication_data_format: \"%s\"", c.PublicationDataFormat)
}
if c.SubscribeProxyName != "" && !slices.Contains(proxyNames, c.SubscribeProxyName) {
return fmt.Errorf("subscribe proxy with name \"%s\" not found", c.SubscribeProxyName)
}
if c.SubscribeProxyEnabled && c.SubscribeProxyName == DefaultProxyName {
if err := validateProxy("default", cfg.Channel.Proxy.Subscribe); err != nil {
return fmt.Errorf("in channel.proxy.subscribe: %v", err)
}
}
if c.PublishProxyName != "" && !slices.Contains(proxyNames, c.PublishProxyName) {
return fmt.Errorf("publish proxy with name \"%s\" not found", c.PublishProxyName)
}
if c.PublishProxyEnabled && c.PublishProxyName == DefaultProxyName {
if err := validateProxy("default", cfg.Channel.Proxy.Publish); err != nil {
return fmt.Errorf("in channel.proxy.publish: %v", err)
}
}
if c.SubRefreshProxyName != "" && !slices.Contains(proxyNames, c.SubRefreshProxyName) {
return fmt.Errorf("sub refresh proxy with name \"%s\" not found", c.SubRefreshProxyName)
}
if c.SubRefreshProxyEnabled && c.SubRefreshProxyName == DefaultProxyName {
if err := validateProxy("default", cfg.Channel.Proxy.SubRefresh); err != nil {
return fmt.Errorf("in channel.proxy.sub_refresh: %v", err)
}
}
if c.SubscribeStreamProxyName != "" && !slices.Contains(proxyNames, c.SubscribeStreamProxyName) {
return fmt.Errorf("subscribe stream proxy with name \"%s\" not found", c.SubscribeStreamProxyName)
}
if c.SubscribeStreamProxyEnabled && c.SubscribeStreamProxyName == DefaultProxyName {
if err := validateProxy("default", cfg.Channel.Proxy.SubscribeStream); err != nil {
return fmt.Errorf("in channel.proxy.subscribe_stream: %v", err)
}
}
if c.Map.PublishProxyName != "" && !slices.Contains(proxyNames, c.Map.PublishProxyName) {
return fmt.Errorf("map publish proxy with name \"%s\" not found", c.Map.PublishProxyName)
}
if c.Map.PublishProxyEnabled && c.Map.PublishProxyName == DefaultProxyName {
if err := validateProxy("default", cfg.Channel.Proxy.MapPublish); err != nil {
return fmt.Errorf("in channel.proxy.map_publish: %v", err)
}
}
if c.Map.RemoveProxyName != "" && !slices.Contains(proxyNames, c.Map.RemoveProxyName) {
return fmt.Errorf("map remove proxy with name \"%s\" not found", c.Map.RemoveProxyName)
}
if c.Map.RemoveProxyEnabled && c.Map.RemoveProxyName == DefaultProxyName {
if err := validateProxy("default", cfg.Channel.Proxy.MapRemove); err != nil {
return fmt.Errorf("in channel.proxy.map_remove: %v", err)
}
}
if c.Map.ClientKey != "" && !slices.Contains([]string{"client_id", "user_id"}, c.Map.ClientKey) {
return fmt.Errorf("unknown map.client_key: %q (valid: \"client_id\", \"user_id\")", c.Map.ClientKey)
}
// map.client_key and the publish/remove proxy both decide the final key.
// When a proxy is configured, it is solely responsible for keying (it may
// echo the client's key or override). Allowing both at once silently lets
// the proxy win over client_key, which surprises users who expect
// client_key to force the keying. Reject the combination at config-load,
// naming the actually-conflicting proxy field.
if c.Map.ClientKey != "" && c.Map.PublishProxyEnabled {
return fmt.Errorf("map.client_key is not compatible with map.publish_proxy_enabled: the proxy is responsible for keying when enabled")
}
if c.Map.ClientKey != "" && c.Map.RemoveProxyEnabled {
return fmt.Errorf("map.client_key is not compatible with map.remove_proxy_enabled: the proxy is responsible for keying when enabled")
}
if c.SubscriptionType != "" && !slices.Contains([]string{"stream", "map", "map_clients", "map_users", "shared_poll"}, c.SubscriptionType) {
return fmt.Errorf("unknown subscription_type: %q", c.SubscriptionType)
}
if c.SubscriptionType == "shared_poll" {
if cfg.SharedPoll.HMACSecretKey == "" {
return fmt.Errorf("shared_poll.hmac_secret_key is required when subscription_type is \"shared_poll\"")
}
if c.SharedPoll.ProxyName != "" && !slices.Contains(proxyNames, c.SharedPoll.ProxyName) {
return fmt.Errorf("shared poll proxy with name %q not found", c.SharedPoll.ProxyName)
}
sharedPollProxyName := c.SharedPoll.ProxyName
if sharedPollProxyName == "" {
sharedPollProxyName = DefaultProxyName
}
if sharedPollProxyName == DefaultProxyName {
if err := validateProxy("default", cfg.Channel.Proxy.SharedPollRefresh); err != nil {
return fmt.Errorf("in channel.proxy.shared_poll_refresh: %v", err)
}
}
if !slices.Contains([]string{"", "versionless", "versioned"}, c.SharedPoll.Mode) {
return fmt.Errorf("invalid shared_poll mode: %q", c.SharedPoll.Mode)
}
if c.SharedPoll.PublishEnabled && (c.SharedPoll.Mode == "" || c.SharedPoll.Mode == "versionless") {
return fmt.Errorf("shared_poll publish_enabled is incompatible with versionless mode (requires explicit versions)")
}
}
if c.Map.Mode != "" && !slices.Contains([]string{"ephemeral", "recoverable", "persistent"}, c.Map.Mode) {
return fmt.Errorf("unknown map.mode: %q (valid: \"ephemeral\", \"recoverable\", \"persistent\")", c.Map.Mode)
}
hasMapType := c.SubscriptionType == "map" || c.SubscriptionType == "map_clients" || c.SubscriptionType == "map_users"
if hasMapType {
if c.Map.Mode == "" {
return fmt.Errorf("map.mode is required when subscription_type is a map type")
}
}
mapHasExpiry := c.Map.Mode == "ephemeral" || c.Map.Mode == "recoverable"
if mapHasExpiry {
if c.Map.KeyTTL == 0 {
return fmt.Errorf("map.key_ttl is required when map.mode is %q", c.Map.Mode)
}
if c.Map.KeyTTL.ToDuration() < 0 {
return fmt.Errorf("map.key_ttl must be positive")
}
}
if c.Map.Mode == "persistent" && c.Map.KeyTTL != 0 {
return fmt.Errorf("map.key_ttl must not be set when map.mode is \"persistent\" (entries don't expire)")
}
// map_clients and map_users rely on TTL as the cleanup fallback: map_clients
// uses MapRemove on disconnect with TTL as the safety net for transient
// remove failures; map_users is *only* cleaned up by TTL (no remove on
// disconnect by design). Persistent mode disables TTL, which means stale
// presence entries can linger indefinitely. Refuse the combination.
if c.Map.Mode == "persistent" && (c.SubscriptionType == "map_clients" || c.SubscriptionType == "map_users") {
return fmt.Errorf("map.mode %q is not allowed with subscription_type %q (presence entries require TTL-based cleanup; use \"ephemeral\" or \"recoverable\")", c.Map.Mode, c.SubscriptionType)
}
if c.Map.Mode == "ephemeral" {
if c.Map.StreamSize > 0 {
return fmt.Errorf("map.stream_size must be 0 for map.mode \"ephemeral\"")
}
if c.Map.StreamTTL != 0 {
return fmt.Errorf("map.stream_ttl must be 0 for map.mode \"ephemeral\"")
}
if c.Map.MetaTTL != 0 {
return fmt.Errorf("map.meta_ttl must be 0 for map.mode \"ephemeral\"")
}
}
mapHasStream := c.Map.Mode == "recoverable" || c.Map.Mode == "persistent"
if mapHasStream {
if c.Map.StreamSize < 0 {
return fmt.Errorf("map.stream_size must be non-negative")
}
if c.Map.StreamTTL.ToDuration() < 0 {
return fmt.Errorf("map.stream_ttl must be non-negative")
}
if c.Map.MetaTTL.ToDuration() < 0 {
return fmt.Errorf("map.meta_ttl must be non-negative")
}
if c.Map.MetaTTL != 0 {
effectiveStreamTTL := c.Map.StreamTTL.ToDuration()
if effectiveStreamTTL == 0 {
effectiveStreamTTL = time.Minute
}
if c.Map.MetaTTL.ToDuration() < effectiveStreamTTL {
return fmt.Errorf("map.meta_ttl (%s) must be >= map.stream_ttl (%s) (metadata must outlive stream)", c.Map.MetaTTL, configtypes.Duration(effectiveStreamTTL))
}
}
}
if c.Map.RemoveClientOnUnsubscribe && !hasMapType {
return fmt.Errorf("map.remove_client_on_unsubscribe requires subscription_type to be a map type")
}
// Map presence prefixes must resolve to a namespace whose subscription_type
// is the matching map-presence flavour. Otherwise publishes to those
// channels would silently fail at runtime.
if c.MapClientsPresenceChannelPrefix != "" {
if err := validatePresenceChannelPrefix(cfg, "map_clients_presence_channel_prefix", c.MapClientsPresenceChannelPrefix, "map_clients"); err != nil {
return err
}
}
if c.MapUsersPresenceChannelPrefix != "" {
if err := validatePresenceChannelPrefix(cfg, "map_users_presence_channel_prefix", c.MapUsersPresenceChannelPrefix, "map_users"); err != nil {
return err
}
}
// Map page-size invariants. Zero means "use default" — the runtime path
// fills in sensible values — but any non-zero combination must satisfy
// min <= default <= max so clamping never produces surprising results.
if c.Map.MinPageSize < 0 {
return fmt.Errorf("map.min_page_size must not be negative")
}
if c.Map.MaxPageSize < 0 {
return fmt.Errorf("map.max_page_size must not be negative")
}
if c.Map.DefaultPageSize < 0 {
return fmt.Errorf("map.default_page_size must not be negative")
}
if c.Map.MinPageSize > 0 && c.Map.MaxPageSize > 0 && c.Map.MinPageSize > c.Map.MaxPageSize {
return fmt.Errorf("map.min_page_size (%d) must not exceed map.max_page_size (%d)", c.Map.MinPageSize, c.Map.MaxPageSize)
}
if c.Map.DefaultPageSize > 0 && c.Map.MaxPageSize > 0 && c.Map.DefaultPageSize > c.Map.MaxPageSize {
return fmt.Errorf("map.default_page_size (%d) must not exceed map.max_page_size (%d)", c.Map.DefaultPageSize, c.Map.MaxPageSize)
}
if c.Map.DefaultPageSize > 0 && c.Map.MinPageSize > 0 && c.Map.DefaultPageSize < c.Map.MinPageSize {
return fmt.Errorf("map.default_page_size (%d) must not be below map.min_page_size (%d)", c.Map.DefaultPageSize, c.Map.MinPageSize)
}
return nil
}
// validatePresenceChannelPrefix checks that prefix, when prepended to a
// channel name, resolves under the configured NamespaceBoundary to a namespace
// whose subscription_type is `expectedType`. Caught at config-load so typos
// in the prefix don't surface only when the first map_publish fails.
func validatePresenceChannelPrefix(cfg Config, fieldName, prefix, expectedType string) error {
boundary := cfg.Channel.NamespaceBoundary
var nsName string
if boundary != "" {
idx := strings.Index(prefix, boundary)
if idx >= 0 {
nsName = prefix[:idx]
}
}
opts, ok, err := channelOpts(&cfg, nsName)
if err != nil {
return fmt.Errorf("%s: %v", fieldName, err)
}
if !ok {
return fmt.Errorf("%s %q: namespace %q does not exist", fieldName, prefix, nsName)
}
if opts.SubscriptionType != expectedType {
return fmt.Errorf("%s %q: target namespace %q must have subscription_type=%q (got %q)", fieldName, prefix, nsName, expectedType, opts.SubscriptionType)
}
return nil
}
func validateRpcOptions(opts configtypes.RpcOptions, rpcProxyNames []string) error {
if opts.ProxyName != "" && !slices.Contains(rpcProxyNames, opts.ProxyName) {
return fmt.Errorf("proxy %s not found for rpc", opts.ProxyName)
}
return nil
}
var proxyNamePattern = "^[-a-zA-Z0-9_.]{2,}$"
var proxyNameRe = regexp.MustCompile(proxyNamePattern)
var consumerNamePattern = "^[a-zA-Z0-9_]{2,}$"
var consumerNameRe = regexp.MustCompile(consumerNamePattern)
func validateSecondPrecisionDuration(duration configtypes.Duration) error {
if duration > 0 && duration.ToDuration()%time.Second != 0 {
return fmt.Errorf("malformed duration %s, sub-second precision is not supported for this key", duration)
}
return nil
}
func validateProxy(name string, p configtypes.Proxy) error {
if !proxyNameRe.MatchString(name) {
return fmt.Errorf("invalid proxy name: %s, must match %s regular expression", name, proxyNamePattern)
}
if p.Timeout == 0 {
return errors.New("timeout not set")
}
if p.Endpoint == "" {
return errors.New("endpoint not set")
}
if err := validateStatusTransforms(p.ProxyCommon.HTTP.StatusToCodeTransforms); err != nil {
return fmt.Errorf("in status_to_code_transforms: %v", err)
}
return nil
}
// Now Centrifugo uses https://github.com/tidwall/gjson to extract custom claims from JWT. So technically
// we could support extracting from nested objects using dot syntax, like "centrifugo.user". But for now
// not using this feature to keep things simple until necessary.
var customClaimRe = regexp.MustCompile("^[a-zA-Z_]+$")
func validateTokens(cfg Config) error {
if cfg.Client.Token.UserIDClaim != "" {
if !customClaimRe.MatchString(cfg.Client.Token.UserIDClaim) {
return fmt.Errorf("invalid token custom user ID claim: %s, must match %s regular expression", cfg.Client.Token.UserIDClaim, customClaimRe.String())
}
}
if cfg.Client.SubscriptionToken.UserIDClaim != "" {
if !customClaimRe.MatchString(cfg.Client.SubscriptionToken.UserIDClaim) {
return fmt.Errorf("invalid subscription token custom user ID claim: %s, must match %s regular expression", cfg.Client.SubscriptionToken.UserIDClaim, customClaimRe.String())
}
}
return nil
}
func validateStatusTransforms(transforms []configtypes.HttpStatusToCodeTransform) error {
for i, transform := range transforms {
if transform.StatusCode == 0 {
return fmt.Errorf("status_code should be set in status_to_code_transforms[%d]", i)
}
if transform.ToDisconnect.Code == 0 && transform.ToError.Code == 0 {
return fmt.Errorf("no error or disconnect code set in status_to_code_transforms[%d]", i)
}
if transform.ToDisconnect.Code > 0 && transform.ToError.Code > 0 {
return fmt.Errorf("only error or disconnect code can be set in status_to_code_transforms[%d], but not both", i)
}
if !tools.IsASCII(transform.ToDisconnect.Reason) {
return fmt.Errorf("status_to_code_transforms[%d] disconnect reason must be ASCII", i)
}
if !tools.IsASCII(transform.ToError.Message) {
return fmt.Errorf("status_to_code_transforms[%d] error message must be ASCII", i)
}
const reasonOrMessageMaxLength = 123 // limit comes from WebSocket close reason length limit. See https://datatracker.ietf.org/doc/html/rfc6455.
if len(transform.ToError.Message) > reasonOrMessageMaxLength {
return fmt.Errorf("status_to_code_transforms[%d] item error message can be up to %d characters long", i, reasonOrMessageMaxLength)
}
if len(transform.ToDisconnect.Reason) > reasonOrMessageMaxLength {
return fmt.Errorf("status_to_code_transforms[%d] disconnect reason can be up to %d characters long", i, reasonOrMessageMaxLength)
}
}
return nil
}
func validateConnectCodeTransforms(transforms []configtypes.ConnectCodeToHTTPResponseTransform) error {
for i, transform := range transforms {
if transform.Code == 0 {
return fmt.Errorf("code should be set in connect_code_to_http_response.transforms[%d]", i)
}
if transform.To.StatusCode == 0 {
return fmt.Errorf("status_code should be set in connect_code_to_http_response.transforms[%d].to", i)
}
}
return nil
}
func validateCodeToUniDisconnectTransforms(transforms configtypes.UniConnectCodeToDisconnectTransforms) error {
for i, t := range transforms {
if t.Code == 0 {
return fmt.Errorf("no code specified in transforms[%d]", i)
}
if t.To.Code == 0 {
return fmt.Errorf("no disconnect code specified in transforms[%d].to", i)
}
if !tools.IsASCII(t.To.Reason) {
return fmt.Errorf("disconnect reason must be ASCII in transforms[%d].to", i)
}
}
return nil
}

View File

@@ -0,0 +1,707 @@
package config
import (
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/stretchr/testify/require"
)
func TestValidatePublicationDataFormat(t *testing.T) {
tests := []struct {
name string
format string
wantErr bool
}{
{
name: "empty string is valid",
format: "",
wantErr: false,
},
{
name: "json is valid",
format: configtypes.PublicationDataFormatJSON,
wantErr: false,
},
{
name: "json_object is valid",
format: configtypes.PublicationDataFormatJSONObject,
wantErr: false,
},
{
name: "binary is valid",
format: configtypes.PublicationDataFormatBinary,
wantErr: false,
},
{
name: "unknown format xml is invalid",
format: "xml",
wantErr: true,
},
{
name: "unknown format text is invalid",
format: "text",
wantErr: true,
},
{
name: "random string is invalid",
format: "foobar",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := DefaultConfig()
cfg.Channel.WithoutNamespace.PublicationDataFormat = tt.format
err := cfg.Validate()
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), "unknown publication_data_format")
} else {
require.NoError(t, err)
}
})
}
}
func TestValidatePublicationDataFormatInNamespace(t *testing.T) {
tests := []struct {
name string
format string
wantErr bool
}{
{
name: "json in namespace",
format: "json",
wantErr: false,
},
{
name: "json_object in namespace",
format: "json_object",
wantErr: false,
},
{
name: "binary in namespace",
format: "binary",
wantErr: false,
},
{
name: "invalid format in namespace",
format: "invalid",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := DefaultConfig()
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: "test",
ChannelOptions: configtypes.ChannelOptions{
PublicationDataFormat: tt.format,
},
},
}
err := cfg.Validate()
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), "unknown publication_data_format")
} else {
require.NoError(t, err)
}
})
}
}
func TestValidateGlobalPublicationDataFormat(t *testing.T) {
tests := []struct {
name string
format string
wantErr bool
}{
{
name: "empty global format is valid",
format: "",
wantErr: false,
},
{
name: "json global format is valid",
format: "json",
wantErr: false,
},
{
name: "binary global format is valid",
format: "binary",
wantErr: false,
},
{
name: "invalid global format",
format: "xml",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := DefaultConfig()
cfg.Channel.PublicationDataFormat = tt.format
err := cfg.Validate()
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), "unknown channel.publication_data_format")
} else {
require.NoError(t, err)
}
})
}
}
// mapDefaultConfig returns a Config suitable as a base for map namespace validation tests.
func mapDefaultConfig() Config {
cfg := DefaultConfig()
return cfg
}
func mapNamespace(name, mode string) configtypes.ChannelNamespace {
return configtypes.ChannelNamespace{
Name: name,
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "map",
Map: configtypes.MapConfig{Mode: mode},
},
}
}
func TestValidateMapNamespace_Ephemeral(t *testing.T) {
t.Run("valid_minimal", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ee", "ephemeral")
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
require.NoError(t, cfg.Validate())
})
t.Run("missing_key_ttl", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ee", "ephemeral")
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "map.key_ttl is required")
})
t.Run("stream_size_rejected", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ee", "ephemeral")
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
ns.Map.StreamSize = 100
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "map.stream_size must be 0")
})
t.Run("stream_ttl_rejected", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ee", "ephemeral")
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
ns.Map.StreamTTL = configtypes.Duration(time.Minute)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "map.stream_ttl must be 0")
})
t.Run("meta_ttl_rejected", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ee", "ephemeral")
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
ns.Map.MetaTTL = configtypes.Duration(10 * time.Minute)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "map.meta_ttl must be 0")
})
}
func TestValidateAutoCacheRecover(t *testing.T) {
t.Run("valid_with_cache_recovery", func(t *testing.T) {
cfg := DefaultConfig()
opts := &cfg.Channel.WithoutNamespace
opts.HistorySize = 1
opts.HistoryTTL = configtypes.Duration(time.Hour)
opts.ForceRecovery = true
opts.ForceRecoveryMode = "cache"
opts.AutoCacheRecover = true
require.NoError(t, cfg.Validate())
})
t.Run("requires_force_recovery", func(t *testing.T) {
cfg := DefaultConfig()
opts := &cfg.Channel.WithoutNamespace
opts.HistorySize = 1
opts.HistoryTTL = configtypes.Duration(time.Hour)
opts.ForceRecoveryMode = "cache"
opts.AutoCacheRecover = true
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "auto_cache_recover requires force_recovery")
})
t.Run("requires_cache_mode", func(t *testing.T) {
cfg := DefaultConfig()
opts := &cfg.Channel.WithoutNamespace
opts.HistorySize = 1
opts.HistoryTTL = configtypes.Duration(time.Hour)
opts.ForceRecovery = true
opts.AutoCacheRecover = true
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "force_recovery_mode set to cache")
})
}
func TestValidateMapNamespace_Recoverable(t *testing.T) {
t.Run("valid_minimal_defaults_zero", func(t *testing.T) {
// Recoverable with all stream options at zero is valid
// because centrifuge auto-derives defaults at runtime.
cfg := mapDefaultConfig()
ns := mapNamespace("ce", "recoverable")
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
require.NoError(t, cfg.Validate())
})
t.Run("valid_explicit_stream_options", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ce", "recoverable")
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
ns.Map.StreamSize = 200
ns.Map.StreamTTL = configtypes.Duration(2 * time.Minute)
ns.Map.MetaTTL = configtypes.Duration(20 * time.Minute)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
require.NoError(t, cfg.Validate())
})
t.Run("missing_key_ttl", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ce", "recoverable")
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "map.key_ttl is required")
})
t.Run("meta_ttl_less_than_stream_ttl", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ce", "recoverable")
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
ns.Map.StreamTTL = configtypes.Duration(10 * time.Minute)
ns.Map.MetaTTL = configtypes.Duration(5 * time.Minute) // less than stream_ttl
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "metadata must outlive stream")
})
t.Run("meta_ttl_equals_stream_ttl_ok", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ce", "recoverable")
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
ns.Map.StreamTTL = configtypes.Duration(5 * time.Minute)
ns.Map.MetaTTL = configtypes.Duration(5 * time.Minute) // equals stream_ttl
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
require.NoError(t, cfg.Validate())
})
t.Run("only_stream_ttl_set_meta_ttl_zero_ok", func(t *testing.T) {
// When only stream_ttl is set, meta_ttl=0 means auto-derived by centrifuge.
cfg := mapDefaultConfig()
ns := mapNamespace("ce", "recoverable")
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
ns.Map.StreamTTL = configtypes.Duration(5 * time.Minute)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
require.NoError(t, cfg.Validate())
})
t.Run("only_meta_ttl_set_stream_ttl_zero_ok", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ce", "recoverable")
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
ns.Map.MetaTTL = configtypes.Duration(10 * time.Minute)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
require.NoError(t, cfg.Validate())
})
t.Run("meta_ttl_less_than_auto_derived_stream_ttl", func(t *testing.T) {
// StreamTTL=0 will be auto-derived to 1min by centrifuge.
// MetaTTL=30s is less than that — must be caught at config time.
cfg := mapDefaultConfig()
ns := mapNamespace("ce", "recoverable")
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
ns.Map.MetaTTL = configtypes.Duration(30 * time.Second) // < auto-derived 1min
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "metadata must outlive stream")
})
t.Run("meta_ttl_equals_auto_derived_stream_ttl_ok", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ce", "recoverable")
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
ns.Map.MetaTTL = configtypes.Duration(1 * time.Minute) // == auto-derived 1min
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
require.NoError(t, cfg.Validate())
})
}
func TestValidateMapNamespace_Persistent(t *testing.T) {
t.Run("valid_minimal", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("cp", "persistent")
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
require.NoError(t, cfg.Validate())
})
t.Run("valid_with_stream_options", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("cp", "persistent")
ns.Map.StreamSize = 500
ns.Map.StreamTTL = configtypes.Duration(5 * time.Minute)
ns.Map.MetaTTL = configtypes.Duration(50 * time.Minute)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
require.NoError(t, cfg.Validate())
})
t.Run("key_ttl_rejected", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("cp", "persistent")
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "map.key_ttl must not be set")
})
t.Run("meta_ttl_less_than_stream_ttl", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("cp", "persistent")
ns.Map.StreamTTL = configtypes.Duration(10 * time.Minute)
ns.Map.MetaTTL = configtypes.Duration(1 * time.Minute)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "metadata must outlive stream")
})
}
func TestValidateMapNamespace_RequiredFields(t *testing.T) {
t.Run("missing_mode", func(t *testing.T) {
cfg := mapDefaultConfig()
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{{
Name: "ns",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "map",
Map: configtypes.MapConfig{},
},
}}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "map.mode is required")
})
t.Run("invalid_mode", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ns", "unknown_mode")
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "unknown map.mode")
})
t.Run("invalid_subscription_type", func(t *testing.T) {
cfg := mapDefaultConfig()
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{{
Name: "ns",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "invalid_type",
},
}}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "unknown subscription_type")
})
}
func TestValidateMapNamespace_SubscriptionType(t *testing.T) {
t.Run("map_clients", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ns", "ephemeral")
ns.SubscriptionType = "map_clients"
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
require.NoError(t, cfg.Validate())
})
t.Run("map_users", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ns", "ephemeral")
ns.SubscriptionType = "map_users"
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
require.NoError(t, cfg.Validate())
})
t.Run("stream_only_no_map_config_needed", func(t *testing.T) {
cfg := DefaultConfig()
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{{
Name: "ns",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "stream",
},
}}
require.NoError(t, cfg.Validate())
})
t.Run("map_clients_persistent_rejected", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ns", "persistent")
ns.SubscriptionType = "map_clients"
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), `map.mode "persistent" is not allowed with subscription_type "map_clients"`)
})
t.Run("map_users_persistent_rejected", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ns", "persistent")
ns.SubscriptionType = "map_users"
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), `map.mode "persistent" is not allowed with subscription_type "map_users"`)
})
t.Run("client_key_with_publish_proxy_rejected", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ns", "ephemeral")
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
ns.Map.ClientKey = "client_id"
ns.Map.PublishProxyEnabled = true
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "map.client_key is not compatible with map.publish_proxy_enabled")
})
t.Run("client_key_with_remove_proxy_rejected", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ns", "ephemeral")
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
ns.Map.ClientKey = "user_id"
ns.Map.RemoveProxyEnabled = true
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "map.client_key is not compatible with")
})
}
func TestValidateMapNamespace_PresenceAndRemoveOptions(t *testing.T) {
t.Run("remove_on_unsubscribe_valid", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("ns", "ephemeral")
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
ns.Map.RemoveClientOnUnsubscribe = true
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
require.NoError(t, cfg.Validate())
})
t.Run("remove_on_unsubscribe_requires_map_type", func(t *testing.T) {
cfg := mapDefaultConfig()
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{{
Name: "ns",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "stream",
Map: configtypes.MapConfig{RemoveClientOnUnsubscribe: true},
},
}}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "map.remove_client_on_unsubscribe requires subscription_type to be a map type")
})
t.Run("map_clients_presence_channel_prefix_valid", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("games", "ephemeral")
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
ns.MapClientsPresenceChannelPrefix = "clients:"
clients := mapNamespace("clients", "ephemeral")
clients.SubscriptionType = "map_clients"
clients.Map.KeyTTL = configtypes.Duration(60 * time.Second)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns, clients}
require.NoError(t, cfg.Validate())
})
t.Run("map_clients_presence_channel_prefix_not_found", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("games", "ephemeral")
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
ns.MapClientsPresenceChannelPrefix = "nonexistent:"
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "map_clients_presence_channel_prefix")
require.Contains(t, err.Error(), "does not exist")
})
t.Run("map_users_presence_channel_prefix_not_found", func(t *testing.T) {
cfg := mapDefaultConfig()
ns := mapNamespace("games", "ephemeral")
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
ns.MapUsersPresenceChannelPrefix = "nonexistent:"
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "map_users_presence_channel_prefix")
require.Contains(t, err.Error(), "does not exist")
})
t.Run("presence_channel_prefix_works_with_stream_type", func(t *testing.T) {
cfg := mapDefaultConfig()
clients := mapNamespace("clients", "ephemeral")
clients.SubscriptionType = "map_clients"
clients.Map.KeyTTL = configtypes.Duration(60 * time.Second)
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{{
Name: "ns",
ChannelOptions: configtypes.ChannelOptions{
SubscriptionType: "stream",
MapClientsPresenceChannelPrefix: "clients:",
},
}, clients}
require.NoError(t, cfg.Validate())
})
}
func TestValidateMapNamespace_MapBrokerType(t *testing.T) {
t.Run("unknown_type", func(t *testing.T) {
cfg := DefaultConfig()
cfg.MapBroker.Type = "unknown"
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "unknown map broker type")
})
for _, brokerType := range []string{"memory", "redis", "postgres"} {
t.Run("valid_"+brokerType, func(t *testing.T) {
cfg := DefaultConfig()
cfg.MapBroker.Type = brokerType
require.NoError(t, cfg.Validate())
})
}
}
func TestValidateConnectCodeToHTTPResponseTransforms(t *testing.T) {
validTransform := configtypes.ConnectCodeToHTTPResponseTransform{
Code: 3500,
To: configtypes.TransformedConnectErrorHttpResponse{
StatusCode: 403,
Body: "forbidden",
},
}
noCode := validTransform
noCode.Code = 0
noStatusCode := validTransform
noStatusCode.To.StatusCode = 0
tests := []struct {
name string
transforms configtypes.ConnectCodeToHTTPResponseTransforms
wantErr string
}{
{
name: "valid transform",
transforms: configtypes.ConnectCodeToHTTPResponseTransforms{validTransform},
},
{
name: "missing code",
transforms: configtypes.ConnectCodeToHTTPResponseTransforms{noCode},
wantErr: "code should be set in connect_code_to_http_response.transforms[0]",
},
{
name: "missing status code",
transforms: configtypes.ConnectCodeToHTTPResponseTransforms{validTransform, noStatusCode},
wantErr: "status_code should be set in connect_code_to_http_response.transforms[1].to",
},
}
// The same transforms are validated for both unidirectional HTTP transports,
// each reporting under its own config path.
transports := []struct {
name string
prefix string
set func(cfg *Config, transforms configtypes.ConnectCodeToHTTPResponseTransforms)
}{
{
name: "uni_sse",
prefix: "in uni_sse.connect_code_to_http_response.transforms: ",
set: func(cfg *Config, transforms configtypes.ConnectCodeToHTTPResponseTransforms) {
cfg.UniSSE.ConnectCodeToHTTPResponse.Transforms = transforms
},
},
{
name: "uni_http_stream",
prefix: "in uni_http_stream.connect_code_to_http_response.transforms: ",
set: func(cfg *Config, transforms configtypes.ConnectCodeToHTTPResponseTransforms) {
cfg.UniHTTPStream.ConnectCodeToHTTPResponse.Transforms = transforms
},
},
}
for _, transport := range transports {
for _, tt := range tests {
t.Run(transport.name+"/"+tt.name, func(t *testing.T) {
cfg := DefaultConfig()
transport.set(&cfg, tt.transforms)
err := cfg.Validate()
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.EqualError(t, err, transport.prefix+tt.wantErr)
})
}
}
}
func TestValidateDuplicateProxyName(t *testing.T) {
proxy := configtypes.Proxy{
Endpoint: "http://localhost:3000/proxy",
Timeout: configtypes.Duration(time.Second),
}
t.Run("unique names are valid", func(t *testing.T) {
cfg := DefaultConfig()
cfg.Proxies = configtypes.NamedProxies{
{Name: "first", Proxy: proxy},
{Name: "second", Proxy: proxy},
}
require.NoError(t, cfg.Validate())
})
t.Run("duplicate name is invalid", func(t *testing.T) {
cfg := DefaultConfig()
cfg.Proxies = configtypes.NamedProxies{
{Name: "first", Proxy: proxy},
{Name: "first", Proxy: proxy},
}
require.EqualError(t, cfg.Validate(), "duplicate proxy name in proxies: first")
})
}

View File

@@ -0,0 +1,48 @@
package confighelpers
import (
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/jwtutils"
"github.com/centrifugal/centrifugo/v6/internal/jwtverify"
)
func MakeVerifierConfig(tokenConf configtypes.Token) (jwtverify.VerifierConfig, error) {
cfg := jwtverify.VerifierConfig{}
cfg.HMACSecretKey = tokenConf.HMACSecretKey
cfg.HMACPreviousSecretKey = tokenConf.HMACPreviousSecretKey
cfg.HMACPreviousSecretKeyValidUntil = tokenConf.HMACPreviousSecretKeyValidUntil
rsaPublicKey := tokenConf.RSAPublicKey
if rsaPublicKey != "" {
pubKey, err := jwtutils.ParseRSAPublicKeyFromPEM([]byte(rsaPublicKey))
if err != nil {
return jwtverify.VerifierConfig{}, fmt.Errorf("error parsing RSA public key: %w", err)
}
cfg.RSAPublicKey = pubKey
}
ecdsaPublicKey := tokenConf.ECDSAPublicKey
if ecdsaPublicKey != "" {
pubKey, err := jwtutils.ParseECDSAPublicKeyFromPEM([]byte(ecdsaPublicKey))
if err != nil {
return jwtverify.VerifierConfig{}, fmt.Errorf("error parsing ECDSA public key: %w", err)
}
cfg.ECDSAPublicKey = pubKey
}
cfg.JWKSPublicEndpoint = tokenConf.JWKSPublicEndpoint
cfg.Audience = tokenConf.Audience
cfg.AudienceRegex = tokenConf.AudienceRegex
cfg.Issuer = tokenConf.Issuer
cfg.IssuerRegex = tokenConf.IssuerRegex
cfg.InsecureSkipJWKSEndpointSafetyCheck = tokenConf.InsecureSkipJWKSEndpointSafetyCheck
if tokenConf.UserIDClaim != "" {
cfg.UserIDClaim = tokenConf.UserIDClaim
}
return cfg, nil
}

View File

@@ -0,0 +1,19 @@
package confighelpers
import (
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifuge"
)
// CentrifugeRedisMapBroker creates a centrifuge.RedisMapBroker from config types.
func CentrifugeRedisMapBroker(n *centrifuge.Node, prefix string, shards []*centrifuge.RedisShard, cfg configtypes.RedisMapBrokerCommon) (*centrifuge.RedisMapBroker, error) {
return centrifuge.NewRedisMapBroker(n, centrifuge.RedisMapBrokerConfig{
Shards: shards,
Prefix: prefix,
CleanupInterval: cfg.CleanupInterval.ToDuration(),
CleanupBatchSize: cfg.CleanupBatchSize,
IdempotentResultTTL: cfg.IdempotentResultTTL.ToDuration(),
SkipPubSub: cfg.SkipPubSub,
})
}

Some files were not shown because too many files have changed in this diff Show More