snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
201
internal/admin/handlers.go
Normal file
201
internal/admin/handlers.go
Normal 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
|
||||
}
|
||||
218
internal/admin/handlers_test.go
Normal file
218
internal/admin/handlers_test.go
Normal 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 := ¢rifuge.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 := ¢rifuge.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
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
747
internal/api/api_test.go
Normal 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
314
internal/api/consuming.go
Normal 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)
|
||||
}
|
||||
132
internal/api/consuming_gen.go
Normal file
132
internal/api/consuming_gen.go
Normal 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
|
||||
}
|
||||
74
internal/api/consuming_test.go
Normal file
74
internal/api/consuming_test.go
Normal 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
77
internal/api/grpc.go
Normal 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
|
||||
}
|
||||
400
internal/api/grpc_handler_gen.go
Normal file
400
internal/api/grpc_handler_gen.go
Normal 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
35
internal/api/grpc_test.go
Normal 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
95
internal/api/handler.go
Normal 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
881
internal/api/handler_gen.go
Normal 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)
|
||||
}
|
||||
774
internal/api/handler_legacy.go
Normal file
774
internal/api/handler_legacy.go
Normal 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
|
||||
}
|
||||
106
internal/api/handler_test.go
Normal file
106
internal/api/handler_test.go
Normal 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
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
1070
internal/apiproto/api.proto
Normal file
File diff suppressed because it is too large
Load Diff
1603
internal/apiproto/api_grpc.pb.go
Normal file
1603
internal/apiproto/api_grpc.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
26
internal/apiproto/decode.go
Normal file
26
internal/apiproto/decode.go
Normal 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)
|
||||
}
|
||||
225
internal/apiproto/decode_request_gen.go
Normal file
225
internal/apiproto/decode_request_gen.go
Normal 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
|
||||
}
|
||||
25
internal/apiproto/encode.go
Normal file
25
internal/apiproto/encode.go
Normal 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)
|
||||
}
|
||||
7
internal/apiproto/encode_error.go
Normal file
7
internal/apiproto/encode_error.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package apiproto
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
func EncodeError(err *Error) ([]byte, error) {
|
||||
return json.Marshal(err)
|
||||
}
|
||||
117
internal/apiproto/encode_response_gen.go
Normal file
117
internal/apiproto/encode_response_gen.go
Normal 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)
|
||||
}
|
||||
115
internal/apiproto/encode_result_gen.go
Normal file
115
internal/apiproto/encode_result_gen.go
Normal 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)
|
||||
}
|
||||
93
internal/apiproto/errors.go
Normal file
93
internal/apiproto/errors.go
Normal 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
36
internal/apiproto/generate.sh
Executable 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
|
||||
27
internal/apiproto/generate_tags.sh
Normal file
27
internal/apiproto/generate_tags.sh
Normal 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
28
internal/apiproto/raw.go
Normal 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
|
||||
}
|
||||
35
internal/apiproto/raw_test.go
Normal file
35
internal/apiproto/raw_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1
internal/apiproto/readme.md
Normal file
1
internal/apiproto/readme.md
Normal file
@@ -0,0 +1 @@
|
||||
Centrifugo server API Protobuf definitions.
|
||||
4195
internal/apiproto/swagger/api.swagger.json
Normal file
4195
internal/apiproto/swagger/api.swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
1523
internal/apiproto/swagger/api.swagger.proto
Normal file
1523
internal/apiproto/swagger/api.swagger.proto
Normal file
File diff suppressed because it is too large
Load Diff
82
internal/apiproto/swagger/generate_swagger.sh
Normal file
82
internal/apiproto/swagger/generate_swagger.sh
Normal 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
|
||||
31
internal/apiproto/swagger/google/api/annotations.proto
Normal file
31
internal/apiproto/swagger/google/api/annotations.proto
Normal 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;
|
||||
}
|
||||
375
internal/apiproto/swagger/google/api/http.proto
Normal file
375
internal/apiproto/swagger/google/api/http.proto
Normal 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¶m=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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
21
internal/app/cmd.go
Normal 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
326
internal/app/engine.go
Normal 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 ¢rifuge.MemoryBrokerConfig{}, nil
|
||||
}
|
||||
|
||||
func memoryPresenceManagerConfig() (*centrifuge.MemoryPresenceManagerConfig, error) {
|
||||
return ¢rifuge.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
23
internal/app/graphite.go
Normal 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
120
internal/app/grpc.go
Normal 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
47
internal/app/log.go
Normal 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
635
internal/app/mux.go
Normal 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
122
internal/app/node.go
Normal 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
49
internal/app/origin.go
Normal 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
533
internal/app/proxy.go
Normal 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
445
internal/app/run.go
Normal 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
81
internal/app/tls.go
Normal 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
10
internal/build/build.go
Normal 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
|
||||
48
internal/cli/checkconfig.go
Normal file
48
internal/cli/checkconfig.go
Normal 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)
|
||||
}
|
||||
}
|
||||
95
internal/cli/checksubtoken.go
Normal file
95
internal/cli/checksubtoken.go
Normal 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)
|
||||
}
|
||||
88
internal/cli/checktoken.go
Normal file
88
internal/cli/checktoken.go
Normal 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
352
internal/cli/configdoc.go
Normal 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(§ion, "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
|
||||
}
|
||||
1502
internal/cli/configdoc/template.html
Normal file
1502
internal/cli/configdoc/template.html
Normal file
File diff suppressed because it is too large
Load Diff
103
internal/cli/defaultconfig.go
Normal file
103
internal/cli/defaultconfig.go
Normal 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
140
internal/cli/defaultenv.go
Normal 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
45
internal/cli/genconfig.go
Normal 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
123
internal/cli/gensubtoken.go
Normal 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
106
internal/cli/gentoken.go
Normal 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
38
internal/cli/serve.go
Normal 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
25
internal/cli/version.go
Normal 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
19
internal/client/client.go
Normal 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
1332
internal/client/handler.go
Normal file
File diff suppressed because it is too large
Load Diff
1876
internal/client/handler_test.go
Normal file
1876
internal/client/handler_test.go
Normal file
File diff suppressed because it is too large
Load Diff
117
internal/client/track_signature.go
Normal file
117
internal/client/track_signature.go
Normal 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
|
||||
}
|
||||
332
internal/client/track_signature_bench_test.go
Normal file
332
internal/client/track_signature_bench_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
229
internal/client/track_signature_test.go
Normal file
229
internal/client/track_signature_test.go
Normal 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"))
|
||||
}
|
||||
1
internal/clientcontext/context.go
Normal file
1
internal/clientcontext/context.go
Normal file
@@ -0,0 +1 @@
|
||||
package clientcontext
|
||||
24
internal/clientcontext/emulated_headers.go
Normal file
24
internal/clientcontext/emulated_headers.go
Normal 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)
|
||||
}
|
||||
5
internal/clientstorage/storage.go
Normal file
5
internal/clientstorage/storage.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package clientstorage
|
||||
|
||||
const (
|
||||
KeyMeta = "meta"
|
||||
)
|
||||
73
internal/config/cache.go
Normal file
73
internal/config/cache.go
Normal 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)
|
||||
}
|
||||
80
internal/config/cache_test.go
Normal file
80
internal/config/cache_test.go
Normal 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
429
internal/config/config.go
Normal 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
|
||||
}
|
||||
164
internal/config/config_test.go
Normal file
164
internal/config/config_test.go
Normal 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
6
internal/config/const.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
DefaultProxyName = "default"
|
||||
TransportErrorMode = "transport"
|
||||
)
|
||||
268
internal/config/container.go
Normal file
268
internal/config/container.go
Normal 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
|
||||
}
|
||||
391
internal/config/container_test.go
Normal file
391
internal/config/container_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
19
internal/config/envconfig/LICENSE
Normal file
19
internal/config/envconfig/LICENSE
Normal 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.
|
||||
2
internal/config/envconfig/MAINTAINERS
Normal file
2
internal/config/envconfig/MAINTAINERS
Normal file
@@ -0,0 +1,2 @@
|
||||
Kelsey Hightower kelsey.hightower@gmail.com github.com/kelseyhightower
|
||||
Travis Parker travis.parker@gmail.com github.com/teepark
|
||||
5
internal/config/envconfig/README.md
Normal file
5
internal/config/envconfig/README.md
Normal 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.
|
||||
8
internal/config/envconfig/doc.go
Normal file
8
internal/config/envconfig/doc.go
Normal 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
|
||||
7
internal/config/envconfig/env_os.go
Normal file
7
internal/config/envconfig/env_os.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build appengine || go1.5
|
||||
|
||||
package envconfig
|
||||
|
||||
import "os"
|
||||
|
||||
var lookupEnv = os.LookupEnv
|
||||
8
internal/config/envconfig/env_syscall.go
Normal file
8
internal/config/envconfig/env_syscall.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build !appengine && !go1.5
|
||||
// +build !appengine,!go1.5
|
||||
|
||||
package envconfig
|
||||
|
||||
import "syscall"
|
||||
|
||||
var lookupEnv = syscall.Getenv
|
||||
425
internal/config/envconfig/envconfig.go
Normal file
425
internal/config/envconfig/envconfig.go
Normal 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
|
||||
}
|
||||
68
internal/config/envconfig/envconfig_1.8_test.go
Normal file
68
internal/config/envconfig/envconfig_1.8_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1100
internal/config/envconfig/envconfig_test.go
Normal file
1100
internal/config/envconfig/envconfig_test.go
Normal file
File diff suppressed because it is too large
Load Diff
36
internal/config/envconfig/testdata/custom.txt
vendored
Normal file
36
internal/config/envconfig/testdata/custom.txt
vendored
Normal 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=
|
||||
183
internal/config/envconfig/testdata/default_list.txt
vendored
Normal file
183
internal/config/envconfig/testdata/default_list.txt
vendored
Normal 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]....
|
||||
40
internal/config/envconfig/testdata/default_table.txt
vendored
Normal file
40
internal/config/envconfig/testdata/default_table.txt
vendored
Normal 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.........................................................................
|
||||
36
internal/config/envconfig/testdata/fault.txt
vendored
Normal file
36
internal/config/envconfig/testdata/fault.txt
vendored
Normal 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
31
internal/config/format.go
Normal 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
|
||||
}
|
||||
159
internal/config/format_test.go
Normal file
159
internal/config/format_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
6
internal/config/migrations.go
Normal file
6
internal/config/migrations.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
func applyConfigMigrations(cfg Config) (Config, []string) {
|
||||
var warnings []string
|
||||
return cfg, warnings
|
||||
}
|
||||
240
internal/config/shared_poll_validate_test.go
Normal file
240
internal/config/shared_poll_validate_test.go
Normal 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
80
internal/config/testdata/config.json
vendored
Normal 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
84
internal/config/testdata/config.toml
vendored
Normal 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
77
internal/config/testdata/config.yaml
vendored
Normal 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
606
internal/config/validate.go
Normal 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
|
||||
}
|
||||
707
internal/config/validate_test.go
Normal file
707
internal/config/validate_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
48
internal/confighelpers/jwt.go
Normal file
48
internal/confighelpers/jwt.go
Normal 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
|
||||
}
|
||||
19
internal/confighelpers/map_broker.go
Normal file
19
internal/confighelpers/map_broker.go
Normal 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
Reference in New Issue
Block a user