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

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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