snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
73
internal/config/cache.go
Normal file
73
internal/config/cache.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"hash/fnv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type cacheItem struct {
|
||||
channel string
|
||||
value channelOptionsResult
|
||||
expires int64
|
||||
}
|
||||
|
||||
type cacheShard struct {
|
||||
// index is unsigned on purpose: it only ever grows, and unsigned overflow
|
||||
// wraps to zero cleanly. With a signed counter the wrap goes negative and
|
||||
// the modulo below yields a negative buffer index, panicking the server
|
||||
// after 2^31 Set calls land on the same shard.
|
||||
index atomic.Uint32
|
||||
size uint32
|
||||
buffer []atomic.Value
|
||||
}
|
||||
|
||||
type rollingCache struct {
|
||||
shards []*cacheShard
|
||||
}
|
||||
|
||||
func newRollingCache(shardSize int, shardCount int) *rollingCache {
|
||||
rc := &rollingCache{
|
||||
shards: make([]*cacheShard, shardCount),
|
||||
}
|
||||
for i := range rc.shards {
|
||||
shard := &cacheShard{
|
||||
size: uint32(shardSize),
|
||||
buffer: make([]atomic.Value, shardSize),
|
||||
}
|
||||
for j := 0; j < shardSize; j++ {
|
||||
shard.buffer[j].Store(&cacheItem{}) // Initialize with zero value.
|
||||
}
|
||||
rc.shards[i] = shard
|
||||
}
|
||||
return rc
|
||||
}
|
||||
|
||||
func (c *rollingCache) shardForKey(key string) *cacheShard {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(key))
|
||||
shardIndex := h.Sum64() % uint64(len(c.shards))
|
||||
return c.shards[shardIndex]
|
||||
}
|
||||
|
||||
func (c *rollingCache) Get(channel string) (channelOptionsResult, bool) {
|
||||
shard := c.shardForKey(channel)
|
||||
for i := 0; i < int(shard.size); i++ {
|
||||
item := shard.buffer[i].Load().(*cacheItem)
|
||||
if item.channel == channel && time.Now().UnixNano() < item.expires {
|
||||
return item.value, true
|
||||
}
|
||||
}
|
||||
return channelOptionsResult{}, false
|
||||
}
|
||||
|
||||
func (c *rollingCache) Set(channel string, value channelOptionsResult, ttl time.Duration) {
|
||||
shard := c.shardForKey(channel)
|
||||
index := int(shard.index.Add(1) % shard.size)
|
||||
item := &cacheItem{
|
||||
channel: channel,
|
||||
value: value,
|
||||
expires: time.Now().Add(ttl).UnixNano(),
|
||||
}
|
||||
shard.buffer[index].Store(item)
|
||||
}
|
||||
80
internal/config/cache_test.go
Normal file
80
internal/config/cache_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRollingCache(t *testing.T) {
|
||||
size := 30
|
||||
shardCount := 1
|
||||
cache := newRollingCache(size, shardCount)
|
||||
|
||||
// Testing Set.
|
||||
channel := "channel1"
|
||||
value := channelOptionsResult{nsName: "value1"}
|
||||
ttl := 200 * time.Millisecond
|
||||
cache.Set(channel, value, ttl)
|
||||
|
||||
// Testing Get after Set.
|
||||
gotValue, ok := cache.Get(channel)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, value, gotValue)
|
||||
|
||||
// Testing Get after expiry.
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
_, ok = cache.Get(channel)
|
||||
require.False(t, ok)
|
||||
|
||||
// Testing if data rolls over.
|
||||
// Setting more data than cache size, to see if rolling over works without errors.
|
||||
for i := 0; i < size+10; i++ {
|
||||
channel := "channel" + strconv.Itoa(i)
|
||||
value := channelOptionsResult{nsName: "value" + strconv.Itoa(i)}
|
||||
cache.Set(channel, value, ttl)
|
||||
}
|
||||
|
||||
// The first 10 items should have been rolled over and replaced.
|
||||
// So, channel0, channel1, ... channel9 should no longer be in the cache.
|
||||
for i := 0; i < 10; i++ {
|
||||
channel := "channel" + strconv.Itoa(i)
|
||||
_, ok = cache.Get(channel)
|
||||
require.False(t, ok, channel)
|
||||
}
|
||||
|
||||
// Whereas, the items from channel10 onwards should still be there.
|
||||
for i := 10; i < size+10; i++ {
|
||||
channel := "channel" + strconv.Itoa(i)
|
||||
gotValue, ok := cache.Get(channel)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, channelOptionsResult{nsName: "value" + strconv.Itoa(i)}, gotValue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRollingCacheIndexOverflow guards against the rolling index wrapping into
|
||||
// negative values. The index is bumped on every Set, so on a long-running node
|
||||
// it eventually overflows; with a signed counter the wrap made the modulo
|
||||
// negative and Set panicked with "index out of range".
|
||||
func TestRollingCacheIndexOverflow(t *testing.T) {
|
||||
cache := newRollingCache(channelOptionsCacheSize, 1)
|
||||
shard := cache.shardForKey("channel")
|
||||
shard.index.Store(math.MaxUint32) // Next Set wraps the index.
|
||||
|
||||
ttl := time.Minute
|
||||
for i := 0; i < 2*channelOptionsCacheSize; i++ {
|
||||
channel := "channel" + strconv.Itoa(i)
|
||||
cache.Set(channel, channelOptionsResult{nsName: "value" + strconv.Itoa(i)}, ttl)
|
||||
}
|
||||
|
||||
// The most recent shardSize items must still be readable after the wrap.
|
||||
for i := channelOptionsCacheSize; i < 2*channelOptionsCacheSize; i++ {
|
||||
channel := "channel" + strconv.Itoa(i)
|
||||
gotValue, ok := cache.Get(channel)
|
||||
require.True(t, ok, channel)
|
||||
require.Equal(t, channelOptionsResult{nsName: "value" + strconv.Itoa(i)}, gotValue)
|
||||
}
|
||||
}
|
||||
429
internal/config/config.go
Normal file
429
internal/config/config.go
Normal file
@@ -0,0 +1,429 @@
|
||||
// Package config contains Centrifugo Config and the code to load it.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/config/envconfig"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/hashicorp/go-envparse"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config contains configuration options of Centrifugo.
|
||||
type Config struct {
|
||||
// HTTP is a configuration for Centrifugo HTTP server.
|
||||
HTTP configtypes.HTTPServer `mapstructure:"http_server" json:"http_server" envconfig:"http_server" toml:"http_server" yaml:"http_server" doc:"Configures the main HTTP server: listen address, ports, and the internal endpoints port."`
|
||||
// Log is a configuration for logging.
|
||||
Log configtypes.Log `mapstructure:"log" json:"log" envconfig:"log" toml:"log" yaml:"log" doc:"Configures logging: level and optional log file."`
|
||||
// Engine is a configuration for Centrifugo engine. It's a handy combination of Broker and PresenceManager.
|
||||
// Currently only memory and redis engines are supported – both implement all the features. For more granular
|
||||
// control use `broker` and `presence_manager` options.
|
||||
Engine configtypes.Engine `mapstructure:"engine" json:"engine" envconfig:"engine" toml:"engine" yaml:"engine" doc:"Selects the engine - a combined broker and presence manager. Supports <<memory>> (default) and <<redis>>. For finer control use <<broker>> and <<presence_manager>> instead."`
|
||||
// Broker allows to configure a message broker to use. Broker is responsible for PUB/SUB functionality
|
||||
// and channel message history and idempotency cache.
|
||||
// By default, memory Broker is used. Memory broker is superfast, but it's not distributed and all
|
||||
// data stored in memory (thus lost after node restart). Redis Broker provides seamless horizontal
|
||||
// scalability, fault-tolerance, and persistence over Centrifugo restarts. Centrifugo also supports
|
||||
// Nats Broker which only implements at most once PUB/SUB semantics.
|
||||
Broker configtypes.Broker `mapstructure:"broker" json:"broker" envconfig:"broker" toml:"broker" yaml:"broker" doc:"Configures the message broker (PUB/SUB, history, idempotency cache) when set separately from the engine. Supports <<memory>> (default, not distributed, lost on restart), <<redis>> (scalable and persistent), and <<nats>> (at-most-once PUB/SUB only)."`
|
||||
// PresenceManager allows to configure a presence manager to use. Presence manager is responsible for
|
||||
// presence information storage and retrieval. By default, memory PresenceManager is used. Memory
|
||||
// PresenceManager is superfast, but it's not distributed. Redis PresenceManager provides a seamless
|
||||
// horizontal scalability.
|
||||
PresenceManager configtypes.PresenceManager `mapstructure:"presence_manager" json:"presence_manager" envconfig:"presence_manager" toml:"presence_manager" yaml:"presence_manager" doc:"Configures the presence manager when set separately from the engine. Supports <<memory>> (default, not distributed) and <<redis>> (scalable)."`
|
||||
// MapBroker allows to configure a map broker for synchronized keyed state channels.
|
||||
// When enabled, clients can use map subscription types for channels in namespaces
|
||||
// that have a map subscription_type.
|
||||
MapBroker configtypes.MapBroker `mapstructure:"map_broker" json:"map_broker" envconfig:"map_broker" toml:"map_broker" yaml:"map_broker" doc:"Configures the map broker for synchronized keyed-state channels. Used by namespaces whose subscription type is <<map>>."`
|
||||
// Controller is a configuration for custom Centrifugo Controller used for
|
||||
// cross-node communication in multi-node clusters.
|
||||
Controller configtypes.Controller `mapstructure:"controller" json:"controller" envconfig:"controller" toml:"controller" yaml:"controller" doc:"Configures the controller used for cross-node communication in multi-node clusters."`
|
||||
|
||||
// SharedPoll contains configuration for shared poll subscriptions.
|
||||
SharedPoll configtypes.SharedPoll `mapstructure:"shared_poll" json:"shared_poll" envconfig:"shared_poll" toml:"shared_poll" yaml:"shared_poll" doc:"Configures shared poll subscriptions."`
|
||||
|
||||
// Client contains real-time client connection related configuration.
|
||||
Client configtypes.Client `mapstructure:"client" json:"client" envconfig:"client" toml:"client" yaml:"client" doc:"Configures real-time client connections: auth, tokens, allowed origins, limits, proxies, and the insecure mode."`
|
||||
// Channel contains real-time channel related configuration.
|
||||
Channel configtypes.Channel `mapstructure:"channel" json:"channel" envconfig:"channel" toml:"channel" yaml:"channel" doc:"Configures channels and namespaces: separators, default options, and per-namespace behavior."`
|
||||
// RPC is a configuration for client RPC calls.
|
||||
RPC configtypes.RPC `mapstructure:"rpc" json:"rpc" envconfig:"rpc" toml:"rpc" yaml:"rpc" doc:"Configures client RPC calls and RPC namespaces."`
|
||||
// Proxies is an array of proxies with custom names for the more granular control of channel-related events
|
||||
// in different channel namespaces.
|
||||
Proxies configtypes.NamedProxies `mapstructure:"proxies" default:"[]" json:"proxies" envconfig:"proxies" yaml:"proxies" toml:"proxies" doc:"Defines named proxies that namespaces can reference to forward channel-related events to your backend."`
|
||||
|
||||
// HttpAPI is a configuration for HTTP server API. It's enabled by default.
|
||||
HttpAPI configtypes.HttpAPI `mapstructure:"http_api" json:"http_api" envconfig:"http_api" toml:"http_api" yaml:"http_api" doc:"Configures the server HTTP API (enabled by default): API key, insecure mode, and whether it's exposed on the external port."`
|
||||
// GrpcAPI is a configuration for gRPC server API. It's disabled by default.
|
||||
GrpcAPI configtypes.GrpcAPI `mapstructure:"grpc_api" json:"grpc_api" envconfig:"grpc_api" toml:"grpc_api" yaml:"grpc_api" doc:"Configures the server gRPC API (disabled by default): enable flag, port, TLS, and auth."`
|
||||
|
||||
// Consumers is a configuration for message queue consumers. For example, Centrifugo can consume
|
||||
// messages from PostgreSQL transactional outbox table, or from Kafka topics.
|
||||
Consumers configtypes.Consumers `mapstructure:"consumers" default:"[]" json:"consumers" envconfig:"consumers" toml:"consumers" yaml:"consumers" doc:"Defines message queue consumers that ingest commands from external sources such as a PostgreSQL outbox table or Kafka topics."`
|
||||
|
||||
// WebSocket configuration. This transport is enabled by default.
|
||||
WebSocket configtypes.WebSocket `mapstructure:"websocket" json:"websocket" envconfig:"websocket" toml:"websocket" yaml:"websocket" doc:"Configures the bidirectional WebSocket transport (enabled by default): message size limits, compression, ping/pong, and write timeouts."`
|
||||
// SSE is a configuration for Server-Sent Events based bidirectional emulation transport.
|
||||
SSE configtypes.SSE `mapstructure:"sse" json:"sse" envconfig:"sse" toml:"sse" yaml:"sse" doc:"Configures the bidirectional Server-Sent Events (EventSource) transport, which relies on the emulation endpoint. Disabled by default."`
|
||||
// HTTPStream is a configuration for HTTP streaming based bidirectional emulation transport.
|
||||
HTTPStream configtypes.HTTPStream `mapstructure:"http_stream" json:"http_stream" envconfig:"http_stream" toml:"http_stream" yaml:"http_stream" doc:"Configures the bidirectional HTTP-streaming transport, which relies on the emulation endpoint. Disabled by default."`
|
||||
// WebTransport is a configuration for WebTransport transport. EXPERIMENTAL.
|
||||
WebTransport configtypes.WebTransport `mapstructure:"webtransport" json:"webtransport" envconfig:"webtransport" toml:"webtransport" yaml:"webtransport" doc:"Configures the bidirectional WebTransport (HTTP/3) transport. Experimental and disabled by default."`
|
||||
// UniSSE is a configuration for unidirectional Server-Sent Events transport.
|
||||
UniSSE configtypes.UniSSE `mapstructure:"uni_sse" json:"uni_sse" envconfig:"uni_sse" toml:"uni_sse" yaml:"uni_sse" doc:"Configures the unidirectional Server-Sent Events (EventSource) transport. Disabled by default."`
|
||||
// UniHTTPStream is a configuration for unidirectional HTTP streaming transport.
|
||||
UniHTTPStream configtypes.UniHTTPStream `mapstructure:"uni_http_stream" json:"uni_http_stream" envconfig:"uni_http_stream" toml:"uni_http_stream" yaml:"uni_http_stream" doc:"Configures the unidirectional HTTP-streaming transport. Disabled by default."`
|
||||
// UniWS is a configuration for unidirectional WebSocket transport.
|
||||
UniWS configtypes.UniWebSocket `mapstructure:"uni_websocket" json:"uni_websocket" envconfig:"uni_websocket" toml:"uni_websocket" yaml:"uni_websocket" doc:"Configures the unidirectional WebSocket transport. Disabled by default."`
|
||||
// UniGRPC is a configuration for unidirectional gRPC transport.
|
||||
UniGRPC configtypes.UniGRPC `mapstructure:"uni_grpc" json:"uni_grpc" envconfig:"uni_grpc" toml:"uni_grpc" yaml:"uni_grpc" doc:"Configures the unidirectional gRPC transport: enable flag, port, and TLS. Disabled by default."`
|
||||
// Emulation endpoint is enabled automatically when at least one bidirectional emulation transport
|
||||
// is configured (SSE or HTTP Stream).
|
||||
Emulation configtypes.Emulation `mapstructure:"emulation" json:"emulation" envconfig:"emulation" toml:"emulation" yaml:"emulation" doc:"Configures the emulation endpoint used by bidirectional SSE and HTTP-streaming transports. Enabled automatically when one of those transports is enabled."`
|
||||
// Admin web UI configuration.
|
||||
Admin configtypes.Admin `mapstructure:"admin" json:"admin" envconfig:"admin" toml:"admin" yaml:"admin" doc:"Configures the admin web UI and its API endpoints."`
|
||||
// Prometheus metrics configuration.
|
||||
Prometheus configtypes.Prometheus `mapstructure:"prometheus" json:"prometheus" envconfig:"prometheus" toml:"prometheus" yaml:"prometheus" doc:"Configures the Prometheus metrics endpoint."`
|
||||
// Health check endpoint configuration.
|
||||
Health configtypes.Health `mapstructure:"health" json:"health" envconfig:"health" toml:"health" yaml:"health" doc:"Configures the health check endpoint."`
|
||||
// Swagger documentation (for server HTTP API) configuration.
|
||||
Swagger configtypes.Swagger `mapstructure:"swagger" json:"swagger" envconfig:"swagger" toml:"swagger" yaml:"swagger" doc:"Configures the Swagger UI endpoint describing the server HTTP API."`
|
||||
// Debug helps to enable Go profiling endpoints.
|
||||
Debug configtypes.Debug `mapstructure:"debug" json:"debug" envconfig:"debug" toml:"debug" yaml:"debug" doc:"Configures Go pprof profiling endpoints. For debugging only - do not expose publicly."`
|
||||
// Init is a configuration for connection initialization endpoint.
|
||||
Init configtypes.ConnInit `mapstructure:"init" json:"init" envconfig:"init" toml:"init" yaml:"init" doc:"Configures the connection initialization endpoint."`
|
||||
|
||||
// OpenTelemetry is a configuration for OpenTelemetry tracing.
|
||||
OpenTelemetry configtypes.OpenTelemetry `mapstructure:"opentelemetry" json:"opentelemetry" envconfig:"opentelemetry" toml:"opentelemetry" yaml:"opentelemetry" doc:"Configures OpenTelemetry tracing export."`
|
||||
// Graphite is a configuration for export metrics to Graphite.
|
||||
Graphite configtypes.Graphite `mapstructure:"graphite" json:"graphite" envconfig:"graphite" toml:"graphite" yaml:"graphite" doc:"Configures exporting metrics to Graphite."`
|
||||
// UsageStats is a configuration for usage stats sending.
|
||||
UsageStats configtypes.UsageStats `mapstructure:"usage_stats" json:"usage_stats" envconfig:"usage_stats" toml:"usage_stats" yaml:"usage_stats" doc:"Configures anonymous usage statistics reporting. Set <<enabled>> to <<false>> to opt out."`
|
||||
// Node is a configuration for Centrifugo Node as part of cluster.
|
||||
Node configtypes.Node `mapstructure:"node" json:"node" envconfig:"node" toml:"node" yaml:"node" doc:"Configures this node as part of a Centrifugo cluster: node name and inter-node info settings."`
|
||||
// Shutdown is a configuration for graceful shutdown.
|
||||
Shutdown configtypes.Shutdown `mapstructure:"shutdown" json:"shutdown" envconfig:"shutdown" toml:"shutdown" yaml:"shutdown" doc:"Configures graceful shutdown behavior, such as the shutdown termination timeout."`
|
||||
|
||||
// PidFile is a path to write a file with Centrifugo process PID.
|
||||
PidFile string `mapstructure:"pid_file" json:"pid_file" envconfig:"pid_file" toml:"pid_file" yaml:"pid_file" expose:"full" doc:"Path of a file to write the Centrifugo process PID to. Leave empty to disable."`
|
||||
// EnableUnreleasedFeatures enables unreleased features. These features are not stable and may be removed even
|
||||
// in minor release update. Evaluate and share feedback if you find some feature useful and want it to be stabilized.
|
||||
EnableUnreleasedFeatures bool `mapstructure:"enable_unreleased_features" json:"enable_unreleased_features" envconfig:"enable_unreleased_features" toml:"enable_unreleased_features" yaml:"enable_unreleased_features" doc:"Enables unstable, unreleased features. These may change or be removed even in a minor release - do not rely on them in production."`
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
FileNotFound bool
|
||||
UnknownKeys []string
|
||||
UnknownEnvs []string
|
||||
KnownEnvVars map[string]envconfig.VarInfo
|
||||
DeprecationWarnings []string
|
||||
}
|
||||
|
||||
func DefineFlags(rootCmd *cobra.Command) {
|
||||
rootCmd.Flags().StringP("pid_file", "", "", "optional path to create PID file")
|
||||
rootCmd.Flags().StringP("http_server.address", "a", "", "interface address to listen on")
|
||||
rootCmd.Flags().StringP("http_server.port", "p", "8000", "port to bind HTTP server to")
|
||||
rootCmd.Flags().StringP("http_server.internal_address", "", "", "custom interface address to listen on for internal endpoints")
|
||||
rootCmd.Flags().StringP("http_server.internal_port", "", "", "custom port for internal endpoints")
|
||||
rootCmd.Flags().StringP("engine.type", "", "memory", "engine to use: memory or redis")
|
||||
rootCmd.Flags().BoolP("broker.enabled", "", false, "enable broker")
|
||||
rootCmd.Flags().StringP("broker.type", "", "memory", "broker to use: memory, redis or nats")
|
||||
rootCmd.Flags().BoolP("presence_manager.enabled", "", false, "enable presence manager")
|
||||
rootCmd.Flags().StringP("presence_manager.type", "", "memory", "presence manager to use: memory or redis")
|
||||
rootCmd.Flags().StringP("map_broker.type", "", "memory", "map broker to use: memory, redis or postgres")
|
||||
rootCmd.Flags().StringP("log.level", "", "info", "set the log level: trace, debug, info, warn, error, fatal or none")
|
||||
rootCmd.Flags().StringP("log.file", "", "", "optional log file - if not specified logs go to STDOUT")
|
||||
rootCmd.Flags().BoolP("debug.enabled", "", false, "enable debug endpoints")
|
||||
rootCmd.Flags().BoolP("admin.enabled", "", false, "enable admin web interface")
|
||||
rootCmd.Flags().BoolP("admin.external", "", false, "expose admin web interface on external port")
|
||||
rootCmd.Flags().BoolP("prometheus.enabled", "", false, "enable Prometheus metrics endpoint")
|
||||
rootCmd.Flags().BoolP("swagger.enabled", "", false, "enable Swagger UI endpoint describing server HTTP API")
|
||||
rootCmd.Flags().BoolP("health.enabled", "", false, "enable health check endpoint")
|
||||
rootCmd.Flags().BoolP("uni_websocket.enabled", "", false, "enable unidirectional websocket endpoint")
|
||||
rootCmd.Flags().BoolP("uni_sse.enabled", "", false, "enable unidirectional SSE (EventSource) endpoint")
|
||||
rootCmd.Flags().BoolP("uni_http_stream.enabled", "", false, "enable unidirectional HTTP-streaming endpoint")
|
||||
rootCmd.Flags().BoolP("sse.enabled", "", false, "enable bidirectional SSE (EventSource) endpoint (with emulation layer)")
|
||||
rootCmd.Flags().BoolP("http_stream.enabled", "", false, "enable bidirectional HTTP-streaming endpoint (with emulation layer)")
|
||||
rootCmd.Flags().BoolP("init.enabled", "", false, "enable connection init endpoint")
|
||||
rootCmd.Flags().BoolP("client.insecure", "", false, "start in insecure client mode")
|
||||
rootCmd.Flags().BoolP("http_api.insecure", "", false, "use insecure API mode")
|
||||
rootCmd.Flags().BoolP("http_api.external", "", false, "expose API handler on external port")
|
||||
rootCmd.Flags().BoolP("admin.insecure", "", false, "use insecure admin mode – no auth required for admin socket")
|
||||
rootCmd.Flags().BoolP("grpc_api.enabled", "", false, "enable GRPC API server")
|
||||
rootCmd.Flags().IntP("grpc_api.port", "", 10000, "port to bind GRPC API server to")
|
||||
rootCmd.Flags().BoolP("uni_grpc.enabled", "", false, "enable unidirectional GRPC endpoint")
|
||||
rootCmd.Flags().IntP("uni_grpc.port", "", 11000, "port to bind unidirectional GRPC server to")
|
||||
}
|
||||
|
||||
func GetConfig(cmd *cobra.Command, configFile string) (Config, Meta, error) {
|
||||
v := viper.NewWithOptions(viper.WithDecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
configtypes.StringToDurationHookFunc(),
|
||||
configtypes.StringToPEMDataHookFunc(),
|
||||
configtypes.StringToMapStringStringHookFunc(),
|
||||
configtypes.StringToStringKeyValuesHookFunc(),
|
||||
)))
|
||||
|
||||
if cmd != nil {
|
||||
bindPFlags := []string{
|
||||
"pid_file", "http_server.port", "http_server.address", "http_server.internal_port",
|
||||
"http_server.internal_address", "log.level", "log.file", "engine.type", "broker.enabled", "broker.type",
|
||||
"presence_manager.enabled", "presence_manager.type", "map_broker.type",
|
||||
"debug.enabled", "admin.enabled", "admin.external",
|
||||
"admin.insecure", "client.insecure", "http_api.insecure", "http_api.external", "prometheus.enabled",
|
||||
"health.enabled", "grpc_api.enabled", "grpc_api.port", "uni_grpc.enabled", "uni_grpc.port",
|
||||
"uni_websocket.enabled", "uni_sse.enabled", "uni_http_stream.enabled", "sse.enabled", "http_stream.enabled",
|
||||
"swagger.enabled", "init.enabled",
|
||||
}
|
||||
for _, flag := range bindPFlags {
|
||||
_ = v.BindPFlag(flag, cmd.Flags().Lookup(flag))
|
||||
}
|
||||
}
|
||||
|
||||
meta := Meta{}
|
||||
|
||||
if configFile != "" {
|
||||
v.SetConfigFile(configFile)
|
||||
err := v.ReadInConfig()
|
||||
if err != nil {
|
||||
var configFileNotFoundError *os.PathError
|
||||
if errors.As(err, &configFileNotFoundError) {
|
||||
meta.FileNotFound = true
|
||||
} else {
|
||||
return Config{}, Meta{}, fmt.Errorf("error reading config file %s: %w", configFile, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conf := &Config{}
|
||||
|
||||
err := v.Unmarshal(conf)
|
||||
if err != nil {
|
||||
return Config{}, Meta{}, fmt.Errorf("error unmarshalling config: %w", err)
|
||||
}
|
||||
|
||||
var flagsSet []string
|
||||
if cmd != nil {
|
||||
cmd.Flags().Visit(func(f *pflag.Flag) {
|
||||
if !slices.Contains(flagsSet, f.Name) {
|
||||
flagsSet = append(flagsSet, f.Name) // pid_file, http_server.port, etc.
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
knownEnvVars := map[string]envconfig.VarInfo{}
|
||||
varInfo, err := envconfig.ProcessWithoutNestedKeys("CENTRIFUGO", conf, flagsSet)
|
||||
if err != nil {
|
||||
return Config{}, Meta{}, fmt.Errorf("error processing env: %w", err)
|
||||
}
|
||||
extendKnownEnvVars(knownEnvVars, varInfo)
|
||||
|
||||
for i, item := range conf.Channel.Namespaces {
|
||||
varInfo, err = envconfig.Process("CENTRIFUGO_CHANNEL_NAMESPACES_"+configtypes.NameForEnv(item.Name), &item)
|
||||
if err != nil {
|
||||
return Config{}, Meta{}, fmt.Errorf("error processing env namespaces: %w", err)
|
||||
}
|
||||
conf.Channel.Namespaces[i] = item
|
||||
extendKnownEnvVars(knownEnvVars, varInfo)
|
||||
}
|
||||
|
||||
for i, item := range conf.RPC.Namespaces {
|
||||
varInfo, err = envconfig.Process("CENTRIFUGO_RPC_NAMESPACES_"+configtypes.NameForEnv(item.Name), &item)
|
||||
if err != nil {
|
||||
return Config{}, Meta{}, fmt.Errorf("error processing env rpc namespaces: %w", err)
|
||||
}
|
||||
conf.RPC.Namespaces[i] = item
|
||||
extendKnownEnvVars(knownEnvVars, varInfo)
|
||||
}
|
||||
|
||||
for i, item := range conf.Proxies {
|
||||
varInfo, err = envconfig.Process("CENTRIFUGO_PROXIES_"+configtypes.NameForEnv(item.Name), &item)
|
||||
if err != nil {
|
||||
return Config{}, Meta{}, fmt.Errorf("error processing env named proxies: %w", err)
|
||||
}
|
||||
conf.Proxies[i] = item
|
||||
extendKnownEnvVars(knownEnvVars, varInfo)
|
||||
}
|
||||
|
||||
for i, item := range conf.Consumers {
|
||||
varInfo, err = envconfig.Process("CENTRIFUGO_CONSUMERS_"+configtypes.NameForEnv(item.Name), &item)
|
||||
if err != nil {
|
||||
return Config{}, Meta{}, fmt.Errorf("error processing env consumers: %w", err)
|
||||
}
|
||||
conf.Consumers[i] = item
|
||||
extendKnownEnvVars(knownEnvVars, varInfo)
|
||||
}
|
||||
|
||||
meta.UnknownKeys = findUnknownKeys(v.AllSettings(), conf, "")
|
||||
meta.UnknownEnvs = checkEnvironmentVars(knownEnvVars)
|
||||
meta.KnownEnvVars = knownEnvVars
|
||||
|
||||
cfg := *conf
|
||||
cfg, deprecationWarnings := applyConfigMigrations(cfg)
|
||||
meta.DeprecationWarnings = deprecationWarnings
|
||||
|
||||
return cfg, meta, nil
|
||||
}
|
||||
|
||||
func extendKnownEnvVars(knownEnvVars map[string]envconfig.VarInfo, varInfo []envconfig.VarInfo) {
|
||||
for _, info := range varInfo {
|
||||
knownEnvVars[info.Key] = info
|
||||
}
|
||||
}
|
||||
|
||||
// findValidKeys recursively finds valid keys in a struct, including embedded structs
|
||||
func findValidKeys(typ reflect.Type, validKeys map[string]reflect.StructField) {
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
tag := field.Tag.Get("mapstructure")
|
||||
|
||||
if tag != "" && tag != ",squash" {
|
||||
// Normal field, add it to validKeys.
|
||||
validKeys[tag] = field
|
||||
} else if field.Anonymous && strings.Contains(tag, "squash") {
|
||||
// Handle embedded fields with "squash".
|
||||
embeddedType := field.Type
|
||||
if embeddedType.Kind() == reflect.Pointer {
|
||||
embeddedType = embeddedType.Elem()
|
||||
}
|
||||
if embeddedType.Kind() == reflect.Struct {
|
||||
// Recursively process the embedded struct
|
||||
findValidKeys(embeddedType, validKeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findUnknownKeys(data map[string]interface{}, configStruct interface{}, parentKey string) []string {
|
||||
var unknownKeys []string
|
||||
val := reflect.ValueOf(configStruct)
|
||||
|
||||
if val.Kind() == reflect.Pointer && val.IsNil() {
|
||||
// Create an instance if the struct pointer is nil to avoid panic.
|
||||
val = reflect.New(val.Type().Elem())
|
||||
}
|
||||
|
||||
if val.Kind() == reflect.Pointer {
|
||||
val = val.Elem()
|
||||
}
|
||||
typ := val.Type()
|
||||
|
||||
// Build a set of valid keys from the struct's mapstructure tags, including embedded structs.
|
||||
validKeys := make(map[string]reflect.StructField)
|
||||
findValidKeys(typ, validKeys)
|
||||
|
||||
// Check each key in the map to see if it's in the valid keys set
|
||||
for key, value := range data {
|
||||
if field, exists := validKeys[key]; exists {
|
||||
fieldValue := val.FieldByName(field.Name)
|
||||
|
||||
if (fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && fieldValue.Type().Elem().Kind() == reflect.Struct)) && !field.Anonymous {
|
||||
if nestedMap, ok := value.(map[string]interface{}); ok {
|
||||
// Handle pointers to structs specifically
|
||||
if fieldValue.Kind() == reflect.Pointer && fieldValue.IsNil() {
|
||||
fieldValue.Set(reflect.New(fieldValue.Type().Elem())) // Create new struct if nil
|
||||
}
|
||||
nestedStruct := fieldValue.Interface()
|
||||
if fieldValue.Kind() == reflect.Pointer {
|
||||
nestedStruct = fieldValue.Elem().Interface()
|
||||
}
|
||||
unknownKeys = append(unknownKeys, findUnknownKeys(nestedMap, nestedStruct, appendKeyPath(parentKey, key))...)
|
||||
}
|
||||
} else if fieldValue.Kind() == reflect.Slice {
|
||||
// Handle each element in the slice if it is a map
|
||||
if slice, ok := value.([]interface{}); ok {
|
||||
for i, elem := range slice {
|
||||
if elemMap, ok := elem.(map[string]interface{}); ok {
|
||||
elementType := fieldValue.Type().Elem()
|
||||
if elementType.Kind() == reflect.Pointer {
|
||||
elementType = elementType.Elem()
|
||||
}
|
||||
if elementType.Kind() == reflect.Struct {
|
||||
nestedStruct := reflect.New(elementType).Interface()
|
||||
unknownKeys = append(unknownKeys, findUnknownKeys(elemMap, nestedStruct, appendKeyPath(appendKeyPath(parentKey, key), fmt.Sprintf("[%d]", i)))...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
unknownKeys = append(unknownKeys, appendKeyPath(parentKey, key))
|
||||
}
|
||||
}
|
||||
|
||||
return unknownKeys
|
||||
}
|
||||
|
||||
func appendKeyPath(parent, key string) string {
|
||||
if parent == "" {
|
||||
return key
|
||||
}
|
||||
return parent + "." + key
|
||||
}
|
||||
|
||||
func checkEnvironmentVars(knownEnvVars map[string]envconfig.VarInfo) []string {
|
||||
var unknownEnvs []string
|
||||
envPrefix := "CENTRIFUGO_"
|
||||
envVars := os.Environ()
|
||||
|
||||
for _, envVar := range envVars {
|
||||
kv, err := envparse.Parse(strings.NewReader(envVar))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for envKey := range kv {
|
||||
if !strings.HasPrefix(envKey, envPrefix) {
|
||||
continue
|
||||
}
|
||||
// Kubernetes automatically adds some variables which are not used by Centrifugo
|
||||
// itself. We skip warnings about them.
|
||||
if isKubernetesEnvVar(envKey) {
|
||||
continue
|
||||
}
|
||||
// Centrifugo can extrapolate ${CENTRIFUGO_VAR_XXX} from env inside MapStringString type.
|
||||
if isCustomEnvVar(envKey) {
|
||||
continue
|
||||
}
|
||||
if _, ok := knownEnvVars[envKey]; !ok {
|
||||
unknownEnvs = append(unknownEnvs, envKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
return unknownEnvs
|
||||
}
|
||||
|
||||
// k8sEnvRegex is used to filter out Kubernetes-injected environment variables.
|
||||
// Centrifugo automatically scans environment variables starting with CENTRIFUGO_ and converts them to configuration options.
|
||||
// However, Kubernetes automatically injects environment variables, so this regular expression filters out Kubernetes-injected environment variables.
|
||||
// See isKubernetesEnvVar function for usage.
|
||||
var k8sEnvRegex = regexp.MustCompile(`^CENTRIFUGO(?:_[A-Z0-9_]+)?_(PORT|SERVICE_)`)
|
||||
|
||||
func isKubernetesEnvVar(envKey string) bool {
|
||||
return k8sEnvRegex.MatchString(envKey)
|
||||
}
|
||||
|
||||
func isCustomEnvVar(envKey string) bool {
|
||||
// Custom environment variables should start with "CENTRIFUGO_VAR_" prefix.
|
||||
// This function checks if the environment variable is a custom one.
|
||||
return strings.HasPrefix(envKey, "CENTRIFUGO_VAR_")
|
||||
}
|
||||
|
||||
// DefaultConfig is a helper to be used in tests.
|
||||
func DefaultConfig() Config {
|
||||
conf, _, err := GetConfig(nil, "")
|
||||
if err != nil {
|
||||
panic("error during getting default config: " + err.Error())
|
||||
}
|
||||
return conf
|
||||
}
|
||||
164
internal/config/config_test.go
Normal file
164
internal/config/config_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func getConfig(t *testing.T, configFile string) (Config, Meta) {
|
||||
t.Helper()
|
||||
conf, meta, err := GetConfig(nil, configFile)
|
||||
require.NoError(t, err)
|
||||
return conf, meta
|
||||
}
|
||||
|
||||
func checkConfig(t *testing.T, conf Config) {
|
||||
t.Helper()
|
||||
require.NotNil(t, conf)
|
||||
require.True(t, conf.HTTP.TLS.Enabled)
|
||||
require.NotZero(t, conf.HTTP.TLS.ServerCAPem)
|
||||
_, err := conf.HTTP.TLS.ToGoTLSConfig("test")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.com/jwks", conf.Client.Token.JWKSPublicEndpoint)
|
||||
require.Len(t, conf.Client.AllowedOrigins, 1)
|
||||
require.Equal(t, "http://localhost:3000", conf.Client.AllowedOrigins[0])
|
||||
require.Len(t, conf.Channel.Namespaces, 2)
|
||||
require.Len(t, conf.Consumers, 1)
|
||||
require.Equal(t, "kafka", conf.Consumers[0].Type)
|
||||
require.Equal(t, "ppp", conf.Proxies[0].Name)
|
||||
require.Equal(t, configtypes.Duration(time.Second), conf.Proxies[0].Timeout)
|
||||
require.Equal(t, true, conf.Consumers[0].Kafka.TLS.Enabled)
|
||||
require.Equal(t, configtypes.Duration(2*time.Second), conf.WebSocket.WriteTimeout)
|
||||
require.Equal(t, "redis", conf.Engine.Type)
|
||||
require.Equal(t, 30*time.Second, time.Duration(conf.Engine.Redis.PresenceTTL))
|
||||
require.Equal(t, []string{"redis:6379"}, conf.Engine.Redis.Address)
|
||||
require.Equal(t, configtypes.MapStringString(map[string]string{"x": "y"}), conf.Client.Proxy.Connect.HTTP.StaticHeaders)
|
||||
require.Equal(t, configtypes.MapStringString(map[string]string{"X": "y"}), conf.Client.Proxy.Refresh.HTTP.StaticHeaders)
|
||||
}
|
||||
|
||||
func TestConfigJSON(t *testing.T) {
|
||||
conf, meta := getConfig(t, "testdata/config.json")
|
||||
checkConfig(t, conf)
|
||||
t.Log("unknown keys", meta.UnknownKeys)
|
||||
t.Log("unknown keys", meta.UnknownEnvs)
|
||||
require.Len(t, meta.UnknownKeys, 0)
|
||||
require.Len(t, meta.UnknownEnvs, 0)
|
||||
}
|
||||
|
||||
func TestConfigYAML(t *testing.T) {
|
||||
conf, _ := getConfig(t, "testdata/config.yaml")
|
||||
checkConfig(t, conf)
|
||||
}
|
||||
|
||||
func TestConfigTOML(t *testing.T) {
|
||||
conf, _ := getConfig(t, "testdata/config.toml")
|
||||
checkConfig(t, conf)
|
||||
}
|
||||
|
||||
func TestConfigEnvVars(t *testing.T) {
|
||||
// Set environment variables for the test.
|
||||
_ = os.Setenv("CENTRIFUGO_CLIENT_ALLOWED_ORIGINS", "* http://localhost:4000")
|
||||
_ = os.Setenv("CENTRIFUGO_CLIENT_TOKEN_JWKS_PUBLIC_ENDPOINT", "https://example.com/jwks/new")
|
||||
_ = os.Setenv("CENTRIFUGO_CONSUMERS_KAFKA_KAFKA_TLS_ENABLED", "false")
|
||||
_ = os.Setenv("CENTRIFUGO_UNKNOWN_ENV", "1")
|
||||
_ = os.Setenv("CENTRIFUGO_CHANNEL_NAMESPACES", `[{"name": "env"}]`)
|
||||
_ = os.Setenv("CENTRIFUGO_CLIENT_PROXY_CONNECT_HTTP_STATIC_HEADERS", `{"key": "value"}`)
|
||||
_ = os.Setenv("CENTRIFUGO_WEBSOCKET_WRITE_TIMEOUT", `300ms`)
|
||||
_ = os.Setenv("CENTRIFUGO_PROXIES", `[]`)
|
||||
_ = os.Setenv("CENTRIFUGO_ENABLE_UNRELEASED_FEATURES", ``) // Empty options should be treated as unset.
|
||||
_ = os.Setenv("CENTRIFUGO_RPC_NAMESPACES", ``) // Empty options should be treated as unset.
|
||||
_ = os.Setenv("CENTRIFUGO_CLIENT_PROXY_CONNECT_TIMEOUT", ``) // Empty unset value should inherit default.
|
||||
defer func() {
|
||||
_ = os.Unsetenv("CENTRIFUGO_CONSUMERS_KAFKA_KAFKA_TLS_ENABLED")
|
||||
_ = os.Unsetenv("CENTRIFUGO_UNKNOWN_ENV")
|
||||
_ = os.Unsetenv("CENTRIFUGO_CLIENT_ALLOWED_ORIGINS")
|
||||
_ = os.Unsetenv("CENTRIFUGO_CLIENT_TOKEN_JWKS_PUBLIC_ENDPOINT")
|
||||
_ = os.Unsetenv("CENTRIFUGO_CHANNEL_NAMESPACES")
|
||||
_ = os.Unsetenv("CENTRIFUGO_CLIENT_PROXY_CONNECT_HTTP_STATIC_HEADERS")
|
||||
_ = os.Unsetenv("CENTRIFUGO_WEBSOCKET_WRITE_TIMEOUT")
|
||||
_ = os.Unsetenv("CENTRIFUGO_PROXIES")
|
||||
_ = os.Unsetenv("CENTRIFUGO_ENABLE_UNRELEASED_FEATURES")
|
||||
_ = os.Unsetenv("CENTRIFUGO_RPC_NAMESPACES")
|
||||
_ = os.Unsetenv("CENTRIFUGO_CLIENT_PROXY_CONNECT_TIMEOUT")
|
||||
}()
|
||||
// Proceed with the test.
|
||||
conf, meta := getConfig(t, "testdata/config.json")
|
||||
require.Equal(t, "https://example.com/jwks/new", conf.Client.Token.JWKSPublicEndpoint)
|
||||
require.Equal(t, false, conf.Consumers[0].Kafka.TLS.Enabled)
|
||||
require.Equal(t, []string{"*", "http://localhost:4000"}, conf.Client.AllowedOrigins)
|
||||
require.Len(t, conf.Channel.Namespaces, 1)
|
||||
require.Equal(t, "env", conf.Channel.Namespaces[0].Name)
|
||||
require.Len(t, meta.UnknownEnvs, 1)
|
||||
require.Len(t, meta.UnknownKeys, 0)
|
||||
require.Contains(t, meta.UnknownEnvs, "CENTRIFUGO_UNKNOWN_ENV")
|
||||
require.Equal(t, configtypes.MapStringString(map[string]string{"key": "value"}), conf.Client.Proxy.Connect.HTTP.StaticHeaders)
|
||||
require.Equal(t, configtypes.Duration(300*time.Millisecond), conf.WebSocket.WriteTimeout)
|
||||
require.Equal(t, time.Second, time.Duration(conf.Client.Proxy.Connect.Timeout))
|
||||
require.Len(t, conf.RPC.Namespaces, 0)
|
||||
require.Len(t, conf.Proxies, 0)
|
||||
}
|
||||
|
||||
func TestConfigEnvVarsNamedArray(t *testing.T) {
|
||||
_ = os.Setenv("CENTRIFUGO_RPC_NAMESPACES", `rpc1 rpc2`)
|
||||
_ = os.Setenv("CENTRIFUGO_RPC_NAMESPACES_RPC1_PROXY_ENABLED", `true`)
|
||||
_ = os.Setenv("CENTRIFUGO_RPC_NAMESPACES_RPC1_PROXY_NAME", `custom`)
|
||||
defer func() {
|
||||
_ = os.Unsetenv("CENTRIFUGO_RPC_NAMESPACES")
|
||||
_ = os.Unsetenv("CENTRIFUGO_RPC_NAMESPACES_RPC1_PROXY_ENABLED")
|
||||
_ = os.Unsetenv("CENTRIFUGO_RPC_NAMESPACES_RPC1_PROXY_NAME")
|
||||
}()
|
||||
conf, meta := getConfig(t, "testdata/config.json")
|
||||
require.Len(t, meta.UnknownEnvs, 0)
|
||||
require.Len(t, conf.RPC.Namespaces, 2)
|
||||
require.Equal(t, "rpc1", conf.RPC.Namespaces[0].Name)
|
||||
require.Equal(t, "rpc2", conf.RPC.Namespaces[1].Name)
|
||||
require.Equal(t, true, conf.RPC.Namespaces[0].ProxyEnabled)
|
||||
require.Equal(t, "custom", conf.RPC.Namespaces[0].ProxyName)
|
||||
}
|
||||
|
||||
// TestIsKubernetesEnvVar validates the isKubernetesEnvVar function.
|
||||
func TestIsKubernetesEnvVar(t *testing.T) {
|
||||
tests := []struct {
|
||||
envKey string
|
||||
expected bool
|
||||
}{
|
||||
{"CENTRIFUGO_PORT_11000_TCP", true},
|
||||
{"CENTRIFUGO_SERVICE_", true},
|
||||
{"CENTRIFUGO_PORT_11000", true},
|
||||
{"CENTRIFUGO_SERVICE_22000", true},
|
||||
{"CENTRIFUGO_APP_PORT_", true},
|
||||
{"CENTRIFUGO_APP_SERVICE_", true},
|
||||
{"CENTRIFUGO_APP_SERVICE_PORT_INTERNAL", true},
|
||||
{"CENTRIFUGO_APP_PORT", true},
|
||||
{"CENTRIFUGO_P0E8NNPNYU_PORT_9000_TCP_PROTO", true},
|
||||
{"CENTRIFUGO_P0E8NNPNYU_PORT_11000_TCP_PORT", true},
|
||||
{"CENTRIFUGO_P0E8NNPNYU_PORT_11000_TCP", true},
|
||||
{"CENTRIFUGO_P0E8NNPNYU_SERVICE_PORT_EXTERNAL", true},
|
||||
{"CENTRIFUGO_P0E8NNPNYU_PORT_11000_TCP_ADDR", true},
|
||||
{"CENTRIFUGO_P0E8NNPNYU_SERVICE_HOST", true},
|
||||
{"CENTRIFUGO_P0E8NNPNYU_PORT", true},
|
||||
{"CENTRIFUGO_P0E8NNPNYU_SERVICE_PORT", true},
|
||||
{"CENTRIFUGO_P0E8NNPNYU_SERVICE_PORT_GRPC", true},
|
||||
|
||||
{"FOO_CENTRIFUGO_PORT_", false},
|
||||
{"CENTRIFUGO__PORT_", false},
|
||||
{"CENTRIFUGO_INVALID_", false},
|
||||
{"CENTRIFUGO_APP", false},
|
||||
{"CENTRIFUGO_APP_INVALID_", false},
|
||||
{"centrifugo_port_", false},
|
||||
|
||||
{"CENTRIFUGO_LOG_FILE", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.envKey, func(t *testing.T) {
|
||||
result := isKubernetesEnvVar(tt.envKey)
|
||||
require.Equal(t, tt.expected, result, "Test failed for input: %s", tt.envKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
6
internal/config/const.go
Normal file
6
internal/config/const.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
DefaultProxyName = "default"
|
||||
TransportErrorMode = "transport"
|
||||
)
|
||||
268
internal/config/container.go
Normal file
268
internal/config/container.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
)
|
||||
|
||||
// Container wraps configuration providing sync access, reload and compilation of required parts.
|
||||
type Container struct {
|
||||
configValue atomic.Value
|
||||
channelOptionsCache *rollingCache
|
||||
ChannelOptionsCacheTTL time.Duration
|
||||
}
|
||||
|
||||
const (
|
||||
channelOptionsCacheSize = 8
|
||||
channelOptionsCacheShards = 128
|
||||
)
|
||||
|
||||
// NewContainer creates new Container.
|
||||
func NewContainer(config Config) (*Container, error) {
|
||||
preparedConfig, err := getPreparedConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Container{
|
||||
channelOptionsCache: newRollingCache(channelOptionsCacheSize, channelOptionsCacheShards),
|
||||
}
|
||||
c.configValue.Store(preparedConfig)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getPreparedConfig(config Config) (*Config, error) {
|
||||
if err := config.Validate(); err != nil {
|
||||
return &config, err
|
||||
}
|
||||
config, err := buildCompiledRegexes(config)
|
||||
if err != nil {
|
||||
return &config, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// Reload node config.
|
||||
func (n *Container) Reload(c Config) error {
|
||||
preparedConfig, err := getPreparedConfig(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n.configValue.Store(preparedConfig)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildCompiledRegexes(config Config) (Config, error) {
|
||||
if config.Channel.WithoutNamespace.ChannelRegex != "" {
|
||||
p, err := regexp.Compile(config.Channel.WithoutNamespace.ChannelRegex)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
config.Channel.WithoutNamespace.Compiled.CompiledChannelRegex = p
|
||||
}
|
||||
|
||||
var namespaces []configtypes.ChannelNamespace
|
||||
for _, ns := range config.Channel.Namespaces {
|
||||
if ns.ChannelRegex == "" {
|
||||
namespaces = append(namespaces, ns)
|
||||
continue
|
||||
}
|
||||
p, err := regexp.Compile(ns.ChannelRegex)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
ns.Compiled.CompiledChannelRegex = p
|
||||
namespaces = append(namespaces, ns)
|
||||
}
|
||||
config.Channel.Namespaces = namespaces
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// namespaceName returns namespace name from channel if exists.
|
||||
func (n *Container) namespaceName(config *Config, ch string) (string, string) {
|
||||
cTrim := strings.TrimPrefix(ch, config.Channel.PrivatePrefix)
|
||||
if config.Channel.NamespaceBoundary != "" && strings.Contains(cTrim, config.Channel.NamespaceBoundary) {
|
||||
parts := strings.SplitN(cTrim, config.Channel.NamespaceBoundary, 2)
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
return "", ch
|
||||
}
|
||||
|
||||
type channelOptionsResult struct {
|
||||
nsName string
|
||||
rest string
|
||||
chOpts configtypes.ChannelOptions
|
||||
ok bool
|
||||
err error
|
||||
}
|
||||
|
||||
// ChannelOptions returns channel options for channel using current channel config.
|
||||
func (n *Container) ChannelOptions(ch string) (string, string, configtypes.ChannelOptions, bool, error) {
|
||||
if n.ChannelOptionsCacheTTL > 0 {
|
||||
if res, ok := n.channelOptionsCache.Get(ch); ok {
|
||||
return res.nsName, res.rest, res.chOpts, res.ok, res.err
|
||||
}
|
||||
}
|
||||
cfg := n.configValue.Load().(*Config)
|
||||
nsName, rest := n.namespaceName(cfg, ch)
|
||||
chOpts, ok, err := channelOpts(cfg, nsName)
|
||||
|
||||
// Apply global publication_data_format default if not set at namespace level
|
||||
if chOpts.PublicationDataFormat == "" && cfg.Channel.PublicationDataFormat != "" {
|
||||
chOpts.PublicationDataFormat = cfg.Channel.PublicationDataFormat
|
||||
}
|
||||
|
||||
res := channelOptionsResult{
|
||||
nsName: nsName,
|
||||
rest: rest,
|
||||
chOpts: chOpts,
|
||||
ok: ok,
|
||||
err: err,
|
||||
}
|
||||
if n.ChannelOptionsCacheTTL > 0 {
|
||||
n.channelOptionsCache.Set(ch, res, n.ChannelOptionsCacheTTL)
|
||||
}
|
||||
return res.nsName, res.rest, res.chOpts, res.ok, res.err
|
||||
}
|
||||
|
||||
// ValidChannelName checks whether the channel name is valid for the resolved
|
||||
// channel options. When channel_regex is set it's matched against rest - the part
|
||||
// of the channel which follows the namespace, as returned by ChannelOptions. When
|
||||
// no channel_regex is set the channel must only contain ASCII characters.
|
||||
func (n *Container) ValidChannelName(ch string, rest string, chOpts configtypes.ChannelOptions) (bool, error) {
|
||||
if chOpts.ChannelRegex != "" {
|
||||
if !chOpts.Compiled.CompiledChannelRegex.MatchString(rest) {
|
||||
return false, nil
|
||||
}
|
||||
} else if !isASCII(ch) {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func isASCII(s string) bool {
|
||||
for _, c := range s {
|
||||
if c > unicode.MaxASCII {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// NumNamespaces returns number of configured namespaces.
|
||||
func (n *Container) NumNamespaces() int {
|
||||
c := n.configValue.Load().(*Config)
|
||||
return len(c.Channel.Namespaces)
|
||||
}
|
||||
|
||||
// channelOpts searches for channel options for specified namespace key.
|
||||
func channelOpts(c *Config, namespaceName string) (configtypes.ChannelOptions, bool, error) {
|
||||
if namespaceName == "" {
|
||||
return c.Channel.WithoutNamespace, true, nil
|
||||
}
|
||||
for _, n := range c.Channel.Namespaces {
|
||||
if n.Name == namespaceName {
|
||||
return n.ChannelOptions, true, nil
|
||||
}
|
||||
}
|
||||
return configtypes.ChannelOptions{}, false, nil
|
||||
}
|
||||
|
||||
// PersonalChannel returns personal channel for user based on node configuration.
|
||||
func (n *Container) PersonalChannel(user string) string {
|
||||
cfg := n.configValue.Load().(*Config)
|
||||
if cfg.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace == "" {
|
||||
return cfg.Channel.UserBoundary + user
|
||||
}
|
||||
return cfg.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace + cfg.Channel.NamespaceBoundary + cfg.Channel.UserBoundary + user
|
||||
}
|
||||
|
||||
// Config returns a copy of node Config.
|
||||
func (n *Container) Config() Config {
|
||||
return *n.configValue.Load().(*Config)
|
||||
}
|
||||
|
||||
// IsPrivateChannel checks if channel requires token to subscribe. In case of
|
||||
// token-protected channel subscription request must contain a proper token.
|
||||
func (n *Container) IsPrivateChannel(ch string) bool {
|
||||
cfg := n.configValue.Load().(*Config)
|
||||
if cfg.Channel.PrivatePrefix == "" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(ch, cfg.Channel.PrivatePrefix)
|
||||
}
|
||||
|
||||
// IsUserLimited returns whether channel is user-limited.
|
||||
func (n *Container) IsUserLimited(ch string) bool {
|
||||
cfg := n.configValue.Load().(*Config)
|
||||
userBoundary := cfg.Channel.UserBoundary
|
||||
if userBoundary == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(ch, userBoundary)
|
||||
}
|
||||
|
||||
// UserAllowed checks if user can subscribe on channel - as channel
|
||||
// can contain special part in the end to indicate which users allowed
|
||||
// to subscribe on it.
|
||||
func (n *Container) UserAllowed(ch string, user string) bool {
|
||||
cfg := n.configValue.Load().(*Config)
|
||||
userBoundary := cfg.Channel.UserBoundary
|
||||
userSeparator := cfg.Channel.UserSeparator
|
||||
if userBoundary == "" {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(ch, userBoundary) {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(ch, userBoundary)
|
||||
if userSeparator == "" {
|
||||
return parts[len(parts)-1] == user
|
||||
}
|
||||
allowedUsers := strings.Split(parts[len(parts)-1], userSeparator)
|
||||
for _, allowedUser := range allowedUsers {
|
||||
if user == allowedUser {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rpcNamespaceName returns rpc namespace name from channel if exists.
|
||||
func (n *Container) rpcNamespaceName(method string) string {
|
||||
cfg := n.configValue.Load().(*Config)
|
||||
if cfg.RPC.NamespaceBoundary != "" && strings.Contains(method, cfg.RPC.NamespaceBoundary) {
|
||||
parts := strings.SplitN(method, cfg.RPC.NamespaceBoundary, 2)
|
||||
return parts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RpcOptions returns rpc options for method using current config.
|
||||
func (n *Container) RpcOptions(method string) (configtypes.RpcOptions, bool, error) {
|
||||
cfg := n.configValue.Load().(*Config)
|
||||
return rpcOpts(cfg, n.rpcNamespaceName(method))
|
||||
}
|
||||
|
||||
// NumRpcNamespaces returns number of configured rpc namespaces.
|
||||
func (n *Container) NumRpcNamespaces() int {
|
||||
cfg := n.configValue.Load().(*Config)
|
||||
return len(cfg.RPC.Namespaces)
|
||||
}
|
||||
|
||||
// rpcOpts searches for channel options for specified namespace key.
|
||||
func rpcOpts(c *Config, namespaceName string) (configtypes.RpcOptions, bool, error) {
|
||||
if namespaceName == "" {
|
||||
return c.RPC.WithoutNamespace, true, nil
|
||||
}
|
||||
for _, n := range c.RPC.Namespaces {
|
||||
if n.Name == namespaceName {
|
||||
return n.RpcOptions, true, nil
|
||||
}
|
||||
}
|
||||
return configtypes.RpcOptions{}, false, nil
|
||||
}
|
||||
391
internal/config/container_test.go
Normal file
391
internal/config/container_test.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func defaultConfig(tb testing.TB) Config {
|
||||
conf := DefaultConfig()
|
||||
require.NotNil(tb, conf)
|
||||
return conf
|
||||
}
|
||||
|
||||
func TestChannelNotFound(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
_, found, err := channelOpts(&c, "xxx")
|
||||
require.False(t, found)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestConfigValidateDefault(t *testing.T) {
|
||||
err := defaultConfig(t).Validate()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestConfigValidateInvalidNamespaceName(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "invalid name",
|
||||
ChannelOptions: configtypes.ChannelOptions{},
|
||||
},
|
||||
}
|
||||
err := c.Validate()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestConfigCompiledChannelRegex(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.WithoutNamespace.ChannelRegex = "^test$"
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "name1",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
ChannelRegex: "^test_ns$",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "name2",
|
||||
ChannelOptions: configtypes.ChannelOptions{},
|
||||
},
|
||||
}
|
||||
ruleContainer, err := NewContainer(c)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, ruleContainer.Config().Channel.WithoutNamespace.CompiledChannelRegex)
|
||||
require.NotNil(t, ruleContainer.Config().Channel.Namespaces[0].CompiledChannelRegex)
|
||||
require.Nil(t, ruleContainer.Config().Channel.Namespaces[1].CompiledChannelRegex)
|
||||
}
|
||||
|
||||
func TestConfigValidateDuplicateNamespaceName(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "name",
|
||||
ChannelOptions: configtypes.ChannelOptions{},
|
||||
},
|
||||
{
|
||||
Name: "name",
|
||||
ChannelOptions: configtypes.ChannelOptions{},
|
||||
},
|
||||
}
|
||||
err := c.Validate()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestConfigValidateNoPersonalNamespace(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{}
|
||||
c.Client.SubscribeToUserPersonalChannel.Enabled = true
|
||||
c.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace = "name"
|
||||
err := c.Validate()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestConfigValidatePersonalSingleConnectionMissingPresence(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{}
|
||||
c.Client.SubscribeToUserPersonalChannel.Enabled = true
|
||||
c.Client.SubscribeToUserPersonalChannel.SingleConnection = true
|
||||
err := c.Validate()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestConfigValidatePersonalSingleConnectionOK(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{}
|
||||
c.Client.SubscribeToUserPersonalChannel.Enabled = true
|
||||
c.Client.SubscribeToUserPersonalChannel.SingleConnection = true
|
||||
c.Channel.WithoutNamespace.Presence = true
|
||||
err := c.Validate()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestConfigValidateHistoryTTL(t *testing.T) {
|
||||
t.Run("in_namespace", func(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "name1",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
HistorySize: 10,
|
||||
HistoryTTL: configtypes.Duration(20 * time.Second),
|
||||
HistoryMetaTTL: configtypes.Duration(10 * time.Second),
|
||||
},
|
||||
},
|
||||
}
|
||||
err := c.Validate()
|
||||
require.ErrorContains(t, err, "history meta ttl")
|
||||
})
|
||||
t.Run("on_top_level", func(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.WithoutNamespace.HistorySize = 10
|
||||
c.Channel.WithoutNamespace.HistoryTTL = configtypes.Duration(31 * 24 * time.Hour)
|
||||
err := c.Validate()
|
||||
require.ErrorContains(t, err, "history meta ttl")
|
||||
})
|
||||
t.Run("top_level_non_default_global", func(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.HistoryMetaTTL = configtypes.Duration(10 * time.Hour)
|
||||
c.Channel.WithoutNamespace.HistorySize = 10
|
||||
c.Channel.WithoutNamespace.HistoryTTL = configtypes.Duration(30 * 24 * time.Hour)
|
||||
err := c.Validate()
|
||||
require.ErrorContains(t, err, "history meta ttl")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigValidatePersonalSingleConnectionNamespacedFail(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{}
|
||||
c.Client.SubscribeToUserPersonalChannel.Enabled = true
|
||||
c.Client.SubscribeToUserPersonalChannel.SingleConnection = true
|
||||
c.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace = "public"
|
||||
err := c.Validate()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestConfigValidatePersonalSingleConnectionNamespacedOK(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{}
|
||||
c.Client.SubscribeToUserPersonalChannel.Enabled = true
|
||||
c.Client.SubscribeToUserPersonalChannel.SingleConnection = true
|
||||
c.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace = "public"
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{{
|
||||
Name: "public",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
Presence: true,
|
||||
},
|
||||
}}
|
||||
err := c.Validate()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestConfigValidateMalformedRecoveryTopLevel(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.WithoutNamespace.ForceRecovery = true
|
||||
err := c.Validate()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestConfigValidateMalformedRecoveryInNamespace(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "name",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
ForceRecovery: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
err := c.Validate()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUserAllowed(t *testing.T) {
|
||||
rules, err := NewContainer(defaultConfig(t))
|
||||
require.NoError(t, err)
|
||||
require.True(t, rules.UserAllowed("channel#1", "1"))
|
||||
require.True(t, rules.UserAllowed("channel", "1"))
|
||||
require.False(t, rules.UserAllowed("channel#1", "2"))
|
||||
require.True(t, rules.UserAllowed("channel#1,2", "1"))
|
||||
require.True(t, rules.UserAllowed("channel#1,2", "2"))
|
||||
require.False(t, rules.UserAllowed("channel#1,2", "3"))
|
||||
}
|
||||
|
||||
func TestValidChannelName(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "digits",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
ChannelRegex: `^\d+$`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "plain",
|
||||
ChannelOptions: configtypes.ChannelOptions{},
|
||||
},
|
||||
{
|
||||
Name: "any",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
ChannelRegex: `^.+$`,
|
||||
},
|
||||
},
|
||||
}
|
||||
container, err := NewContainer(c)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
channel string
|
||||
valid bool
|
||||
}{
|
||||
{"no regex, ascii channel", "plain:index", true},
|
||||
{"no regex, non-ascii channel", "plain:индекс", false},
|
||||
{"regex matches rest, not the whole channel", "digits:42", true},
|
||||
{"regex does not match rest", "digits:abc", false},
|
||||
{"regex replaces the ascii check", "any:индекс", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, rest, chOpts, found, err := container.ChannelOptions(tt.channel)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
valid, err := container.ValidChannelName(tt.channel, rest, chOpts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.valid, valid)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUserLimited(t *testing.T) {
|
||||
rules, err := NewContainer(defaultConfig(t))
|
||||
require.NoError(t, err)
|
||||
require.True(t, rules.IsUserLimited("#12"))
|
||||
require.True(t, rules.IsUserLimited("test#12"))
|
||||
config := rules.Config()
|
||||
config.Channel.UserBoundary = ""
|
||||
err = rules.Reload(config)
|
||||
require.NoError(t, err)
|
||||
require.False(t, rules.IsUserLimited("#12"))
|
||||
}
|
||||
|
||||
func BenchmarkContainer_ChannelOptions(b *testing.B) {
|
||||
cfg := defaultConfig(b)
|
||||
|
||||
const numNamespaces = 128
|
||||
|
||||
var channels []string
|
||||
|
||||
var namespaces []configtypes.ChannelNamespace
|
||||
for i := 0; i < numNamespaces; i++ {
|
||||
namespaces = append(namespaces, configtypes.ChannelNamespace{
|
||||
Name: "test" + strconv.Itoa(i),
|
||||
})
|
||||
channels = append(channels, "test"+strconv.Itoa(i)+":123")
|
||||
}
|
||||
cfg.Channel.Namespaces = namespaces
|
||||
|
||||
c, _ := NewContainer(cfg)
|
||||
c.ChannelOptionsCacheTTL = 200 * time.Millisecond
|
||||
|
||||
b.ReportAllocs()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
i++
|
||||
ch := channels[i%numNamespaces]
|
||||
nsName, _, _, ok, _ := c.ChannelOptions(ch)
|
||||
if !ok {
|
||||
b.Fatal("ns not found")
|
||||
}
|
||||
if !strings.HasPrefix(ch, nsName) {
|
||||
b.Fatal("wrong ns name: " + nsName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPublicationDataFormatInheritance(t *testing.T) {
|
||||
t.Run("namespace inherits global format", func(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.PublicationDataFormat = "json"
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "test",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
// No PublicationDataFormat set
|
||||
},
|
||||
},
|
||||
}
|
||||
container, err := NewContainer(c)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Channel without namespace should use global format
|
||||
_, _, chOpts, ok, err := container.ChannelOptions("mychannel")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "json", chOpts.PublicationDataFormat)
|
||||
|
||||
// Namespace without explicit format should inherit global
|
||||
_, _, chOpts, ok, err = container.ChannelOptions("test:mychannel")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "json", chOpts.PublicationDataFormat)
|
||||
})
|
||||
|
||||
t.Run("namespace overrides global format", func(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
c.Channel.PublicationDataFormat = "json"
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "test",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
PublicationDataFormat: "binary",
|
||||
},
|
||||
},
|
||||
}
|
||||
container, err := NewContainer(c)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Namespace with explicit format should use its own
|
||||
_, _, chOpts, ok, err := container.ChannelOptions("test:mychannel")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "binary", chOpts.PublicationDataFormat)
|
||||
})
|
||||
|
||||
t.Run("no global format set", func(t *testing.T) {
|
||||
c := defaultConfig(t)
|
||||
// No global format set
|
||||
c.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "test",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
// No PublicationDataFormat set
|
||||
},
|
||||
},
|
||||
}
|
||||
container, err := NewContainer(c)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should use empty string (default behavior)
|
||||
_, _, chOpts, ok, err := container.ChannelOptions("test:mychannel")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "", chOpts.PublicationDataFormat)
|
||||
})
|
||||
}
|
||||
|
||||
var testConfig Config
|
||||
|
||||
func BenchmarkContainer_Config(b *testing.B) {
|
||||
cfg := defaultConfig(b)
|
||||
var namespaces []configtypes.ChannelNamespace
|
||||
for i := 0; i < 100; i++ {
|
||||
namespaces = append(namespaces, configtypes.ChannelNamespace{
|
||||
Name: "test" + strconv.Itoa(i),
|
||||
})
|
||||
}
|
||||
cfg.Channel.Namespaces = namespaces
|
||||
c, _ := NewContainer(cfg)
|
||||
c.ChannelOptionsCacheTTL = 200 * time.Millisecond
|
||||
|
||||
b.ReportAllocs()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
testConfig = c.Config()
|
||||
if len(testConfig.Channel.Namespaces) != 100 {
|
||||
b.Fatal("wrong config")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
19
internal/config/envconfig/LICENSE
Normal file
19
internal/config/envconfig/LICENSE
Normal file
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2013 Kelsey Hightower
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
2
internal/config/envconfig/MAINTAINERS
Normal file
2
internal/config/envconfig/MAINTAINERS
Normal file
@@ -0,0 +1,2 @@
|
||||
Kelsey Hightower kelsey.hightower@gmail.com github.com/kelseyhightower
|
||||
Travis Parker travis.parker@gmail.com github.com/teepark
|
||||
5
internal/config/envconfig/README.md
Normal file
5
internal/config/envconfig/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# envconfig
|
||||
|
||||
This is a fork of https://github.com/kelseyhightower/envconfig to make it fit Centrifugo, original license left unchanged.
|
||||
|
||||
There are changes to have access to VarInfo and to avoid using defaults for non-zero values.
|
||||
8
internal/config/envconfig/doc.go
Normal file
8
internal/config/envconfig/doc.go
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2013 Kelsey Hightower. All rights reserved.
|
||||
// Use of this source code is governed by the MIT License that can be found in
|
||||
// the LICENSE file.
|
||||
|
||||
// Package envconfig implements decoding of environment variables based on a user
|
||||
// defined specification. A typical use is using environment variables for
|
||||
// configuration settings.
|
||||
package envconfig
|
||||
7
internal/config/envconfig/env_os.go
Normal file
7
internal/config/envconfig/env_os.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build appengine || go1.5
|
||||
|
||||
package envconfig
|
||||
|
||||
import "os"
|
||||
|
||||
var lookupEnv = os.LookupEnv
|
||||
8
internal/config/envconfig/env_syscall.go
Normal file
8
internal/config/envconfig/env_syscall.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build !appengine && !go1.5
|
||||
// +build !appengine,!go1.5
|
||||
|
||||
package envconfig
|
||||
|
||||
import "syscall"
|
||||
|
||||
var lookupEnv = syscall.Getenv
|
||||
425
internal/config/envconfig/envconfig.go
Normal file
425
internal/config/envconfig/envconfig.go
Normal file
@@ -0,0 +1,425 @@
|
||||
// Copyright (c) 2013 Kelsey Hightower. All rights reserved.
|
||||
// Use of this source code is governed by the MIT License that can be found in
|
||||
// the LICENSE file.
|
||||
|
||||
package envconfig
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrInvalidSpecification indicates that a specification is of the wrong type.
|
||||
var ErrInvalidSpecification = errors.New("specification must be a struct pointer")
|
||||
|
||||
var gatherRegexp = regexp.MustCompile("([^A-Z]+|[A-Z]+[^A-Z]+|[A-Z]+)")
|
||||
var acronymRegexp = regexp.MustCompile("([A-Z]+)([A-Z][^A-Z]+)")
|
||||
|
||||
// A ParseError occurs when an environment variable cannot be converted to
|
||||
// the type required by a struct field during assignment.
|
||||
type ParseError struct {
|
||||
KeyName string
|
||||
FieldName string
|
||||
TypeName string
|
||||
Value string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Decoder has the same semantics as Setter, but takes higher precedence.
|
||||
// It is provided for historical compatibility.
|
||||
type Decoder interface {
|
||||
Decode(value string) error
|
||||
}
|
||||
|
||||
// Setter is implemented by types can self-deserialize values.
|
||||
// Any type that implements flag.Value also implements Setter.
|
||||
type Setter interface {
|
||||
Set(value string) error
|
||||
}
|
||||
|
||||
func (e *ParseError) Error() string {
|
||||
return fmt.Sprintf("envconfig.Process: assigning %[1]s to %[2]s: converting '%[3]s' to type %[4]s. details: %[5]s", e.KeyName, e.FieldName, e.Value, e.TypeName, e.Err)
|
||||
}
|
||||
|
||||
// VarInfo maintains information about the configuration variable
|
||||
type VarInfo struct {
|
||||
Name string
|
||||
Alt string
|
||||
Key string
|
||||
NestedKey string
|
||||
Field reflect.Value
|
||||
Tags reflect.StructTag
|
||||
|
||||
NonZeroInFile bool
|
||||
}
|
||||
|
||||
// GatherInfo gathers information about the specified struct
|
||||
func gatherInfo(prefix string, spec interface{}) ([]VarInfo, error) {
|
||||
s := reflect.ValueOf(spec)
|
||||
|
||||
if s.Kind() != reflect.Pointer {
|
||||
return nil, ErrInvalidSpecification
|
||||
}
|
||||
s = s.Elem()
|
||||
if s.Kind() != reflect.Struct {
|
||||
return nil, ErrInvalidSpecification
|
||||
}
|
||||
typeOfSpec := s.Type()
|
||||
|
||||
// over allocate an info array, we will extend if needed later
|
||||
infos := make([]VarInfo, 0, s.NumField())
|
||||
for i := 0; i < s.NumField(); i++ {
|
||||
f := s.Field(i)
|
||||
ftype := typeOfSpec.Field(i)
|
||||
if !f.CanSet() || isTrue(ftype.Tag.Get("ignored")) {
|
||||
continue
|
||||
}
|
||||
|
||||
for f.Kind() == reflect.Pointer {
|
||||
if f.IsNil() {
|
||||
if f.Type().Elem().Kind() != reflect.Struct {
|
||||
// nil pointer to a non-struct: leave it alone
|
||||
break
|
||||
}
|
||||
// nil pointer to struct: create a zero instance
|
||||
f.Set(reflect.New(f.Type().Elem()))
|
||||
}
|
||||
f = f.Elem()
|
||||
}
|
||||
|
||||
name := strings.ToUpper(ftype.Tag.Get("envconfig"))
|
||||
if name == "" {
|
||||
name = ftype.Name
|
||||
}
|
||||
|
||||
// Capture information about the config variable
|
||||
info := VarInfo{
|
||||
Name: name,
|
||||
Field: f,
|
||||
Tags: ftype.Tag,
|
||||
//Alt: strings.ToUpper(ftype.Tag.Get("envconfig")),
|
||||
NonZeroInFile: !f.IsZero(),
|
||||
}
|
||||
|
||||
// Default to the field name as the env var name (will be upcased)
|
||||
info.Key = info.Name
|
||||
|
||||
// Best effort to un-pick camel casing as separate words
|
||||
if isTrue(ftype.Tag.Get("split_words")) {
|
||||
words := gatherRegexp.FindAllStringSubmatch(ftype.Name, -1)
|
||||
if len(words) > 0 {
|
||||
var name []string
|
||||
for _, words := range words {
|
||||
if m := acronymRegexp.FindStringSubmatch(words[0]); len(m) == 3 {
|
||||
name = append(name, m[1], m[2])
|
||||
} else {
|
||||
name = append(name, words[0])
|
||||
}
|
||||
}
|
||||
|
||||
info.Key = strings.Join(name, "_")
|
||||
}
|
||||
}
|
||||
if info.Alt != "" {
|
||||
info.Key = info.Alt
|
||||
}
|
||||
if prefix != "" {
|
||||
info.Key = fmt.Sprintf("%s_%s", prefix, info.Key)
|
||||
}
|
||||
info.Key = strings.ToUpper(info.Key)
|
||||
|
||||
// Construct NestedKey: lowercase, without prefix.
|
||||
info.NestedKey = strings.ToLower(info.Name)
|
||||
|
||||
infos = append(infos, info)
|
||||
|
||||
if f.Kind() == reflect.Struct {
|
||||
// honor Decode if present
|
||||
if decoderFrom(f) == nil && setterFrom(f) == nil && textUnmarshaler(f) == nil && binaryUnmarshaler(f) == nil {
|
||||
innerPrefix := prefix
|
||||
if !ftype.Anonymous {
|
||||
innerPrefix = info.Key
|
||||
}
|
||||
|
||||
embeddedPtr := f.Addr().Interface()
|
||||
embeddedInfos, err := gatherInfo(innerPrefix, embeddedPtr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Update NestedKey for embedded fields.
|
||||
for i := range embeddedInfos {
|
||||
if !ftype.Anonymous {
|
||||
nestedPrefix := strings.ToLower(info.Name)
|
||||
embeddedInfos[i].NestedKey = nestedPrefix + "." + embeddedInfos[i].NestedKey
|
||||
}
|
||||
}
|
||||
infos = append(infos[:len(infos)-1], embeddedInfos...)
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// CheckDisallowed checks that no environment variables with the prefix are set
|
||||
// that we don't know how or want to parse. This is likely only meaningful with
|
||||
// a non-empty prefix.
|
||||
func CheckDisallowed(prefix string, spec interface{}) error {
|
||||
infos, err := gatherInfo(prefix, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vars := make(map[string]struct{})
|
||||
for _, info := range infos {
|
||||
vars[info.Key] = struct{}{}
|
||||
}
|
||||
|
||||
if prefix != "" {
|
||||
prefix = strings.ToUpper(prefix) + "_"
|
||||
}
|
||||
|
||||
for _, env := range os.Environ() {
|
||||
if !strings.HasPrefix(env, prefix) {
|
||||
continue
|
||||
}
|
||||
v := strings.SplitN(env, "=", 2)[0]
|
||||
if _, found := vars[v]; !found {
|
||||
return fmt.Errorf("unknown environment variable %s", v)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Process populates the specified struct based on environment variables
|
||||
func Process(prefix string, spec interface{}) ([]VarInfo, error) {
|
||||
return ProcessWithoutNestedKeys(prefix, spec, nil)
|
||||
}
|
||||
|
||||
// ProcessWithoutNestedKeys populates the specified struct based on environment variables
|
||||
// but excludes fields whose NestedKey is in the excludeKeys list
|
||||
func ProcessWithoutNestedKeys(prefix string, spec interface{}, excludeKeys []string) ([]VarInfo, error) {
|
||||
infos, err := gatherInfo(prefix, spec)
|
||||
|
||||
for _, info := range infos {
|
||||
// Skip processing if this key is excluded.
|
||||
if slices.Contains(excludeKeys, info.NestedKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
// `os.Getenv` cannot differentiate between an explicitly set empty value
|
||||
// and an unset value. `os.LookupEnv` is preferred to `syscall.Getenv`,
|
||||
// but it is only available in go1.5 or newer. We're using Go build tags
|
||||
// here to use os.LookupEnv for >=go1.5
|
||||
value, ok := lookupEnv(info.Key)
|
||||
if !ok && info.Alt != "" {
|
||||
value, ok = lookupEnv(info.Alt)
|
||||
}
|
||||
|
||||
var defaultIsUsed bool
|
||||
def := info.Tags.Get("default")
|
||||
if def != "" && (!ok || value == "") { // Empty values are considered unset.
|
||||
value = def
|
||||
defaultIsUsed = true
|
||||
}
|
||||
|
||||
req := info.Tags.Get("required")
|
||||
if !ok && def == "" {
|
||||
if isTrue(req) {
|
||||
key := info.Key
|
||||
if info.Alt != "" {
|
||||
key = info.Alt
|
||||
}
|
||||
return nil, fmt.Errorf("required key %s missing value", key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
err = processField(value, info.Field, defaultIsUsed)
|
||||
if err != nil {
|
||||
return nil, &ParseError{
|
||||
KeyName: info.Key,
|
||||
FieldName: info.Name,
|
||||
TypeName: info.Field.Type().String(),
|
||||
Value: value,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return infos, err
|
||||
}
|
||||
|
||||
// MustProcess is the same as Process but panics if an error occurs
|
||||
func MustProcess(prefix string, spec interface{}) {
|
||||
if _, err := Process(prefix, spec); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
const SliceSep = " "
|
||||
|
||||
func processField(value string, field reflect.Value, defaultIsUsed bool) error {
|
||||
// If the value is default but field is not zero already, we don't want to overwrite it.
|
||||
if defaultIsUsed && !field.IsZero() {
|
||||
return nil
|
||||
}
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
typ := field.Type()
|
||||
|
||||
decoder := decoderFrom(field)
|
||||
if decoder != nil {
|
||||
return decoder.Decode(value)
|
||||
}
|
||||
// look for Set method if Decode not defined
|
||||
setter := setterFrom(field)
|
||||
if setter != nil {
|
||||
return setter.Set(value)
|
||||
}
|
||||
|
||||
if t := textUnmarshaler(field); t != nil {
|
||||
return t.UnmarshalText([]byte(value))
|
||||
}
|
||||
|
||||
if b := binaryUnmarshaler(field); b != nil {
|
||||
return b.UnmarshalBinary([]byte(value))
|
||||
}
|
||||
|
||||
if typ.Kind() == reflect.Pointer {
|
||||
typ = typ.Elem()
|
||||
if field.IsNil() {
|
||||
field.Set(reflect.New(typ))
|
||||
}
|
||||
field = field.Elem()
|
||||
}
|
||||
|
||||
switch typ.Kind() {
|
||||
case reflect.String:
|
||||
field.SetString(value)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
var (
|
||||
val int64
|
||||
err error
|
||||
)
|
||||
if field.Kind() == reflect.Int64 && typ.Name() == "Duration" {
|
||||
var d time.Duration
|
||||
d, err = time.ParseDuration(value)
|
||||
val = int64(d)
|
||||
} else {
|
||||
val, err = strconv.ParseInt(value, 0, typ.Bits())
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
field.SetInt(val)
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
val, err := strconv.ParseUint(value, 0, typ.Bits())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field.SetUint(val)
|
||||
case reflect.Bool:
|
||||
val, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field.SetBool(val)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
val, err := strconv.ParseFloat(value, typ.Bits())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field.SetFloat(val)
|
||||
case reflect.Slice:
|
||||
sl := reflect.MakeSlice(typ, 0, 0)
|
||||
if typ.Elem().Kind() == reflect.Uint8 {
|
||||
sl = reflect.ValueOf([]byte(value))
|
||||
} else if strings.TrimSpace(value) != "" {
|
||||
vals := strings.Split(value, SliceSep)
|
||||
sl = reflect.MakeSlice(typ, len(vals), len(vals))
|
||||
for i, val := range vals {
|
||||
err := processField(val, sl.Index(i), defaultIsUsed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
field.Set(sl)
|
||||
case reflect.Map:
|
||||
mp := reflect.MakeMap(typ)
|
||||
if strings.TrimSpace(value) != "" {
|
||||
pairs := strings.Split(value, ",")
|
||||
for _, pair := range pairs {
|
||||
kvpair := strings.Split(pair, ":")
|
||||
if len(kvpair) != 2 {
|
||||
return fmt.Errorf("invalid map item: %q", pair)
|
||||
}
|
||||
k := reflect.New(typ.Key()).Elem()
|
||||
err := processField(kvpair[0], k, defaultIsUsed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v := reflect.New(typ.Elem()).Elem()
|
||||
err = processField(kvpair[1], v, defaultIsUsed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mp.SetMapIndex(k, v)
|
||||
}
|
||||
}
|
||||
field.Set(mp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func interfaceFrom(field reflect.Value, fn func(interface{}, *bool)) {
|
||||
// it may be impossible for a struct field to fail this check
|
||||
if !field.CanInterface() {
|
||||
return
|
||||
}
|
||||
var ok bool
|
||||
fn(field.Interface(), &ok)
|
||||
if !ok && field.CanAddr() {
|
||||
fn(field.Addr().Interface(), &ok)
|
||||
}
|
||||
}
|
||||
|
||||
func decoderFrom(field reflect.Value) (d Decoder) {
|
||||
interfaceFrom(field, func(v interface{}, ok *bool) { d, *ok = v.(Decoder) })
|
||||
return d
|
||||
}
|
||||
|
||||
func setterFrom(field reflect.Value) (s Setter) {
|
||||
interfaceFrom(field, func(v interface{}, ok *bool) { s, *ok = v.(Setter) })
|
||||
return s
|
||||
}
|
||||
|
||||
func textUnmarshaler(field reflect.Value) (t encoding.TextUnmarshaler) {
|
||||
interfaceFrom(field, func(v interface{}, ok *bool) { t, *ok = v.(encoding.TextUnmarshaler) })
|
||||
return t
|
||||
}
|
||||
|
||||
func binaryUnmarshaler(field reflect.Value) (b encoding.BinaryUnmarshaler) {
|
||||
interfaceFrom(field, func(v interface{}, ok *bool) { b, *ok = v.(encoding.BinaryUnmarshaler) })
|
||||
return b
|
||||
}
|
||||
|
||||
func isTrue(s string) bool {
|
||||
b, _ := strconv.ParseBool(s)
|
||||
return b
|
||||
}
|
||||
68
internal/config/envconfig/envconfig_1.8_test.go
Normal file
68
internal/config/envconfig/envconfig_1.8_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
//go:build go1.8
|
||||
|
||||
package envconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type SpecWithURL struct {
|
||||
UrlValue url.URL
|
||||
UrlPointer *url.URL
|
||||
}
|
||||
|
||||
func TestParseURL(t *testing.T) {
|
||||
var s SpecWithURL
|
||||
|
||||
os.Clearenv()
|
||||
_ = os.Setenv("ENV_CONFIG_URLVALUE", "https://github.com/kelseyhightower/envconfig")
|
||||
_ = os.Setenv("ENV_CONFIG_URLPOINTER", "https://github.com/kelseyhightower/envconfig")
|
||||
|
||||
_, err := Process("env_config", &s)
|
||||
if err != nil {
|
||||
t.Fatal("unexpected error:", err)
|
||||
}
|
||||
|
||||
u, err := url.Parse("https://github.com/kelseyhightower/envconfig")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if s.UrlValue != *u {
|
||||
t.Errorf("expected %q, got %q", u, s.UrlValue.String())
|
||||
}
|
||||
|
||||
if *s.UrlPointer != *u {
|
||||
t.Errorf("expected %q, got %q", u, s.UrlPointer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseURLError(t *testing.T) {
|
||||
var s SpecWithURL
|
||||
|
||||
os.Clearenv()
|
||||
_ = os.Setenv("ENV_CONFIG_URLPOINTER", "http_://foo")
|
||||
|
||||
_, err := Process("env_config", &s)
|
||||
|
||||
v, ok := err.(*ParseError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ParseError, got %T %v", err, err)
|
||||
}
|
||||
if v.FieldName != "UrlPointer" {
|
||||
t.Errorf("expected %s, got %v", "UrlPointer", v.FieldName)
|
||||
}
|
||||
|
||||
expectedUnderlyingError := url.Error{
|
||||
Op: "parse",
|
||||
URL: "http_://foo",
|
||||
Err: errors.New("first path segment in URL cannot contain colon"),
|
||||
}
|
||||
|
||||
if v.Err.Error() != expectedUnderlyingError.Error() {
|
||||
t.Errorf("expected %q, got %q", expectedUnderlyingError, v.Err)
|
||||
}
|
||||
}
|
||||
1100
internal/config/envconfig/envconfig_test.go
Normal file
1100
internal/config/envconfig/envconfig_test.go
Normal file
File diff suppressed because it is too large
Load Diff
36
internal/config/envconfig/testdata/custom.txt
vendored
Normal file
36
internal/config/envconfig/testdata/custom.txt
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
ENV_CONFIG_ENABLED=some.embedded.value
|
||||
ENV_CONFIG_EMBEDDEDPORT=
|
||||
ENV_CONFIG_MULTIWORDVAR=
|
||||
ENV_CONFIG_MULTI_WITH_DIFFERENT_ALT=
|
||||
ENV_CONFIG_EMBEDDED_WITH_ALT=
|
||||
ENV_CONFIG_DEBUG=
|
||||
ENV_CONFIG_PORT=
|
||||
ENV_CONFIG_RATE=
|
||||
ENV_CONFIG_USER=
|
||||
ENV_CONFIG_TTL=
|
||||
ENV_CONFIG_TIMEOUT=
|
||||
ENV_CONFIG_ADMINUSERS=
|
||||
ENV_CONFIG_MAGICNUMBERS=
|
||||
ENV_CONFIG_EMPTYNUMBERS=
|
||||
ENV_CONFIG_BYTESLICE=
|
||||
ENV_CONFIG_COLORCODES=
|
||||
ENV_CONFIG_MULTIWORDVAR=
|
||||
ENV_CONFIG_MULTI_WORD_VAR_WITH_AUTO_SPLIT=
|
||||
ENV_CONFIG_MULTI_WORD_ACR_WITH_AUTO_SPLIT=
|
||||
ENV_CONFIG_SOMEPOINTER=
|
||||
ENV_CONFIG_SOMEPOINTERWITHDEFAULT=foorbar.is.the.word
|
||||
ENV_CONFIG_MULTI_WORD_VAR_WITH_ALT=what.alt
|
||||
ENV_CONFIG_MULTI_WORD_VAR_WITH_LOWER_CASE_ALT=
|
||||
ENV_CONFIG_SERVICE_HOST=
|
||||
ENV_CONFIG_DEFAULTVAR=
|
||||
ENV_CONFIG_REQUIREDVAR=
|
||||
ENV_CONFIG_BROKER=
|
||||
ENV_CONFIG_REQUIREDDEFAULT=
|
||||
ENV_CONFIG_OUTER_INNER=
|
||||
ENV_CONFIG_OUTER_PROPERTYWITHDEFAULT=
|
||||
ENV_CONFIG_AFTERNESTED=
|
||||
ENV_CONFIG_HONOR=
|
||||
ENV_CONFIG_DATETIME=
|
||||
ENV_CONFIG_MAPFIELD=
|
||||
ENV_CONFIG_URLVALUE=
|
||||
ENV_CONFIG_URLPOINTER=
|
||||
183
internal/config/envconfig/testdata/default_list.txt
vendored
Normal file
183
internal/config/envconfig/testdata/default_list.txt
vendored
Normal file
@@ -0,0 +1,183 @@
|
||||
This.application.is.configured.via.the.environment..The.following.environment
|
||||
variables.can.be.used:
|
||||
|
||||
ENV_CONFIG_ENABLED
|
||||
..[description].some.embedded.value
|
||||
..[type]........True.or.False
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_EMBEDDEDPORT
|
||||
..[description].
|
||||
..[type]........Integer
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_MULTIWORDVAR
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_MULTI_WITH_DIFFERENT_ALT
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_EMBEDDED_WITH_ALT
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_DEBUG
|
||||
..[description].
|
||||
..[type]........True.or.False
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_PORT
|
||||
..[description].
|
||||
..[type]........Integer
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_RATE
|
||||
..[description].
|
||||
..[type]........Float
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_USER
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_TTL
|
||||
..[description].
|
||||
..[type]........Unsigned.Integer
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_TIMEOUT
|
||||
..[description].
|
||||
..[type]........Duration
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_ADMINUSERS
|
||||
..[description].
|
||||
..[type]........Comma-separated.list.of.String
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_MAGICNUMBERS
|
||||
..[description].
|
||||
..[type]........Comma-separated.list.of.Integer
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_EMPTYNUMBERS
|
||||
..[description].
|
||||
..[type]........Comma-separated.list.of.Integer
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_BYTESLICE
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_COLORCODES
|
||||
..[description].
|
||||
..[type]........Comma-separated.list.of.String:Integer.pairs
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_MULTIWORDVAR
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_MULTI_WORD_VAR_WITH_AUTO_SPLIT
|
||||
..[description].
|
||||
..[type]........Unsigned.Integer
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_MULTI_WORD_ACR_WITH_AUTO_SPLIT
|
||||
..[description].
|
||||
..[type]........Unsigned.Integer
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_SOMEPOINTER
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_SOMEPOINTERWITHDEFAULT
|
||||
..[description].foorbar.is.the.word
|
||||
..[type]........String
|
||||
..[default].....foo2baz
|
||||
..[required]....
|
||||
ENV_CONFIG_MULTI_WORD_VAR_WITH_ALT
|
||||
..[description].what.alt
|
||||
..[type]........String
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_MULTI_WORD_VAR_WITH_LOWER_CASE_ALT
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_SERVICE_HOST
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_DEFAULTVAR
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....foobar
|
||||
..[required]....
|
||||
ENV_CONFIG_REQUIREDVAR
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....
|
||||
..[required]....true
|
||||
ENV_CONFIG_BROKER
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....127.0.0.1
|
||||
..[required]....
|
||||
ENV_CONFIG_REQUIREDDEFAULT
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....foo2bar
|
||||
..[required]....true
|
||||
ENV_CONFIG_OUTER_INNER
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_OUTER_PROPERTYWITHDEFAULT
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....fuzzybydefault
|
||||
..[required]....
|
||||
ENV_CONFIG_AFTERNESTED
|
||||
..[description].
|
||||
..[type]........String
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_HONOR
|
||||
..[description].
|
||||
..[type]........HonorDecodeInStruct
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_DATETIME
|
||||
..[description].
|
||||
..[type]........Time
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_MAPFIELD
|
||||
..[description].
|
||||
..[type]........Comma-separated.list.of.String:String.pairs
|
||||
..[default].....one:two,three:four
|
||||
..[required]....
|
||||
ENV_CONFIG_URLVALUE
|
||||
..[description].
|
||||
..[type]........CustomURL
|
||||
..[default].....
|
||||
..[required]....
|
||||
ENV_CONFIG_URLPOINTER
|
||||
..[description].
|
||||
..[type]........CustomURL
|
||||
..[default].....
|
||||
..[required]....
|
||||
40
internal/config/envconfig/testdata/default_table.txt
vendored
Normal file
40
internal/config/envconfig/testdata/default_table.txt
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
This.application.is.configured.via.the.environment..The.following.environment
|
||||
variables.can.be.used:
|
||||
|
||||
KEY..............................................TYPE............................................DEFAULT...............REQUIRED....DESCRIPTION
|
||||
ENV_CONFIG_ENABLED...............................True.or.False.....................................................................some.embedded.value
|
||||
ENV_CONFIG_EMBEDDEDPORT..........................Integer...........................................................................
|
||||
ENV_CONFIG_MULTIWORDVAR..........................String............................................................................
|
||||
ENV_CONFIG_MULTI_WITH_DIFFERENT_ALT..............String............................................................................
|
||||
ENV_CONFIG_EMBEDDED_WITH_ALT.....................String............................................................................
|
||||
ENV_CONFIG_DEBUG.................................True.or.False.....................................................................
|
||||
ENV_CONFIG_PORT..................................Integer...........................................................................
|
||||
ENV_CONFIG_RATE..................................Float.............................................................................
|
||||
ENV_CONFIG_USER..................................String............................................................................
|
||||
ENV_CONFIG_TTL...................................Unsigned.Integer..................................................................
|
||||
ENV_CONFIG_TIMEOUT...............................Duration..........................................................................
|
||||
ENV_CONFIG_ADMINUSERS............................Comma-separated.list.of.String....................................................
|
||||
ENV_CONFIG_MAGICNUMBERS..........................Comma-separated.list.of.Integer...................................................
|
||||
ENV_CONFIG_EMPTYNUMBERS..........................Comma-separated.list.of.Integer...................................................
|
||||
ENV_CONFIG_BYTESLICE.............................String............................................................................
|
||||
ENV_CONFIG_COLORCODES............................Comma-separated.list.of.String:Integer.pairs......................................
|
||||
ENV_CONFIG_MULTIWORDVAR..........................String............................................................................
|
||||
ENV_CONFIG_MULTI_WORD_VAR_WITH_AUTO_SPLIT........Unsigned.Integer..................................................................
|
||||
ENV_CONFIG_MULTI_WORD_ACR_WITH_AUTO_SPLIT........Unsigned.Integer..................................................................
|
||||
ENV_CONFIG_SOMEPOINTER...........................String............................................................................
|
||||
ENV_CONFIG_SOMEPOINTERWITHDEFAULT................String..........................................foo2baz...........................foorbar.is.the.word
|
||||
ENV_CONFIG_MULTI_WORD_VAR_WITH_ALT...............String............................................................................what.alt
|
||||
ENV_CONFIG_MULTI_WORD_VAR_WITH_LOWER_CASE_ALT....String............................................................................
|
||||
ENV_CONFIG_SERVICE_HOST..........................String............................................................................
|
||||
ENV_CONFIG_DEFAULTVAR............................String..........................................foobar............................
|
||||
ENV_CONFIG_REQUIREDVAR...........................String................................................................true........
|
||||
ENV_CONFIG_BROKER................................String..........................................127.0.0.1.........................
|
||||
ENV_CONFIG_REQUIREDDEFAULT.......................String..........................................foo2bar...............true........
|
||||
ENV_CONFIG_OUTER_INNER...........................String............................................................................
|
||||
ENV_CONFIG_OUTER_PROPERTYWITHDEFAULT.............String..........................................fuzzybydefault....................
|
||||
ENV_CONFIG_AFTERNESTED...........................String............................................................................
|
||||
ENV_CONFIG_HONOR.................................HonorDecodeInStruct...............................................................
|
||||
ENV_CONFIG_DATETIME..............................Time..............................................................................
|
||||
ENV_CONFIG_MAPFIELD..............................Comma-separated.list.of.String:String.pairs.....one:two,three:four................
|
||||
ENV_CONFIG_URLVALUE..............................CustomURL.........................................................................
|
||||
ENV_CONFIG_URLPOINTER............................CustomURL.........................................................................
|
||||
36
internal/config/envconfig/testdata/fault.txt
vendored
Normal file
36
internal/config/envconfig/testdata/fault.txt
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
{.Key}
|
||||
31
internal/config/format.go
Normal file
31
internal/config/format.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/tools"
|
||||
)
|
||||
|
||||
// ValidatePublicationData validates publication data according to the specified format.
|
||||
// Returns an error if the data doesn't match the format requirements.
|
||||
func ValidatePublicationData(data []byte, format string) error {
|
||||
switch format {
|
||||
case configtypes.PublicationDataFormatJSON:
|
||||
if !tools.IsValidJSON(data) {
|
||||
return errors.New("data is not valid JSON")
|
||||
}
|
||||
case configtypes.PublicationDataFormatJSONObject:
|
||||
if !tools.IsValidJSONObject(data) {
|
||||
return errors.New("data is not valid JSON object")
|
||||
}
|
||||
case configtypes.PublicationDataFormatBinary:
|
||||
// Binary format allows empty data, no validation needed
|
||||
default:
|
||||
// Default behavior: reject empty data
|
||||
if len(data) == 0 {
|
||||
return errors.New("data required")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
159
internal/config/format_test.go
Normal file
159
internal/config/format_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidatePublicationData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
format string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
// Default format tests
|
||||
{
|
||||
name: "default format - non-empty data allowed",
|
||||
data: []byte("any data"),
|
||||
format: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "default format - empty data rejected",
|
||||
data: []byte{},
|
||||
format: "",
|
||||
wantErr: true,
|
||||
errMsg: "data required",
|
||||
},
|
||||
// JSON format tests
|
||||
{
|
||||
name: "json format - valid json object",
|
||||
data: []byte(`{"key": "value"}`),
|
||||
format: configtypes.PublicationDataFormatJSON,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json format - valid json array",
|
||||
data: []byte(`[1, 2, 3]`),
|
||||
format: configtypes.PublicationDataFormatJSON,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json format - valid json primitives",
|
||||
data: []byte(`"string"`),
|
||||
format: configtypes.PublicationDataFormatJSON,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json format - valid json null",
|
||||
data: []byte(`null`),
|
||||
format: configtypes.PublicationDataFormatJSON,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json format - invalid json",
|
||||
data: []byte(`not valid json`),
|
||||
format: configtypes.PublicationDataFormatJSON,
|
||||
wantErr: true,
|
||||
errMsg: "data is not valid JSON",
|
||||
},
|
||||
{
|
||||
name: "json format - empty data",
|
||||
data: []byte{},
|
||||
format: configtypes.PublicationDataFormatJSON,
|
||||
wantErr: true,
|
||||
errMsg: "data is not valid JSON",
|
||||
},
|
||||
// JSON object format tests
|
||||
{
|
||||
name: "json_object format - valid json object",
|
||||
data: []byte(`{"key": "value"}`),
|
||||
format: configtypes.PublicationDataFormatJSONObject,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json_object format - valid json object with whitespace",
|
||||
data: []byte(` {"key": "value"} `),
|
||||
format: configtypes.PublicationDataFormatJSONObject,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json_object format - json array rejected",
|
||||
data: []byte(`[1, 2, 3]`),
|
||||
format: configtypes.PublicationDataFormatJSONObject,
|
||||
wantErr: true,
|
||||
errMsg: "data is not valid JSON object",
|
||||
},
|
||||
{
|
||||
name: "json_object format - json string rejected",
|
||||
data: []byte(`"string"`),
|
||||
format: configtypes.PublicationDataFormatJSONObject,
|
||||
wantErr: true,
|
||||
errMsg: "data is not valid JSON object",
|
||||
},
|
||||
{
|
||||
name: "json_object format - json null rejected",
|
||||
data: []byte(`null`),
|
||||
format: configtypes.PublicationDataFormatJSONObject,
|
||||
wantErr: true,
|
||||
errMsg: "data is not valid JSON object",
|
||||
},
|
||||
{
|
||||
name: "json_object format - json number rejected",
|
||||
data: []byte(`42`),
|
||||
format: configtypes.PublicationDataFormatJSONObject,
|
||||
wantErr: true,
|
||||
errMsg: "data is not valid JSON object",
|
||||
},
|
||||
{
|
||||
name: "json_object format - invalid json",
|
||||
data: []byte(`not valid json`),
|
||||
format: configtypes.PublicationDataFormatJSONObject,
|
||||
wantErr: true,
|
||||
errMsg: "data is not valid JSON object",
|
||||
},
|
||||
{
|
||||
name: "json_object format - empty data",
|
||||
data: []byte{},
|
||||
format: configtypes.PublicationDataFormatJSONObject,
|
||||
wantErr: true,
|
||||
errMsg: "data is not valid JSON object",
|
||||
},
|
||||
// Binary format tests
|
||||
{
|
||||
name: "binary format - binary data allowed",
|
||||
data: []byte{0x01, 0x02, 0x03},
|
||||
format: configtypes.PublicationDataFormatBinary,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "binary format - empty data allowed",
|
||||
data: []byte{},
|
||||
format: configtypes.PublicationDataFormatBinary,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "binary format - text data allowed",
|
||||
data: []byte("any text"),
|
||||
format: configtypes.PublicationDataFormatBinary,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidatePublicationData(tt.data, tt.format)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.errMsg)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
6
internal/config/migrations.go
Normal file
6
internal/config/migrations.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
func applyConfigMigrations(cfg Config) (Config, []string) {
|
||||
var warnings []string
|
||||
return cfg, warnings
|
||||
}
|
||||
240
internal/config/shared_poll_validate_test.go
Normal file
240
internal/config/shared_poll_validate_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func sharedPollDefaultProxy() configtypes.Proxy {
|
||||
return configtypes.Proxy{
|
||||
Endpoint: "http://localhost:3001/refresh",
|
||||
Timeout: configtypes.Duration(5 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedPollConfig_Validation_MissingSecret(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "poll",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "shared_poll",
|
||||
},
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "hmac_secret_key")
|
||||
}
|
||||
|
||||
func TestSharedPollConfig_Validation_WithSecret(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.SharedPoll.HMACSecretKey = "my-secret"
|
||||
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "poll",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "shared_poll",
|
||||
},
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSharedPollConfig_Validation_InvalidProxyName(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.SharedPoll.HMACSecretKey = "my-secret"
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "poll",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "shared_poll",
|
||||
SharedPoll: configtypes.SharedPollConfig{
|
||||
ProxyName: "nonexistent_proxy",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "shared poll proxy")
|
||||
require.Contains(t, err.Error(), "nonexistent_proxy")
|
||||
}
|
||||
|
||||
func TestSharedPollConfig_Validation_ValidProxyName(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.SharedPoll.HMACSecretKey = "my-secret"
|
||||
cfg.Proxies = []configtypes.NamedProxy{
|
||||
{
|
||||
Name: "poll_backend",
|
||||
Proxy: configtypes.Proxy{
|
||||
Endpoint: "http://localhost:3001/refresh",
|
||||
Timeout: configtypes.Duration(5 * time.Second),
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "poll",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "shared_poll",
|
||||
SharedPoll: configtypes.SharedPollConfig{
|
||||
ProxyName: "poll_backend",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSharedPollConfig_SubscriptionTypeValid(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.SharedPoll.HMACSecretKey = "secret"
|
||||
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "poll",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "shared_poll",
|
||||
},
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSharedPollConfig_UnknownSubscriptionType(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Channel.WithoutNamespace.SubscriptionType = "unknown_type"
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown subscription_type")
|
||||
}
|
||||
|
||||
func TestSharedPollConfig_WithoutNamespaceValidation(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.SharedPoll.HMACSecretKey = "secret"
|
||||
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
|
||||
cfg.Channel.WithoutNamespace.SubscriptionType = "shared_poll"
|
||||
err := cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSharedPollConfig_WithoutNamespaceMissingSecret(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
|
||||
cfg.Channel.WithoutNamespace.SubscriptionType = "shared_poll"
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "hmac_secret_key")
|
||||
}
|
||||
|
||||
func TestSharedPollConfig_VersionlessPublishEnabled(t *testing.T) {
|
||||
t.Run("empty_mode_publish_enabled_rejected", func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.SharedPoll.HMACSecretKey = "secret"
|
||||
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "poll",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "shared_poll",
|
||||
SharedPoll: configtypes.SharedPollConfig{
|
||||
Mode: "",
|
||||
PublishEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "publish_enabled is incompatible with versionless")
|
||||
})
|
||||
|
||||
t.Run("versionless_mode_publish_enabled_rejected", func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.SharedPoll.HMACSecretKey = "secret"
|
||||
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "poll",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "shared_poll",
|
||||
SharedPoll: configtypes.SharedPollConfig{
|
||||
Mode: "versionless",
|
||||
PublishEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "publish_enabled is incompatible with versionless")
|
||||
})
|
||||
|
||||
t.Run("full_mode_publish_enabled_accepted", func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.SharedPoll.HMACSecretKey = "secret"
|
||||
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "poll",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "shared_poll",
|
||||
SharedPoll: configtypes.SharedPollConfig{
|
||||
Mode: "versioned",
|
||||
PublishEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("versionless_no_publish_accepted", func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.SharedPoll.HMACSecretKey = "secret"
|
||||
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "poll",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "shared_poll",
|
||||
SharedPoll: configtypes.SharedPollConfig{
|
||||
Mode: "versionless",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("versionless_string_accepted", func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.SharedPoll.HMACSecretKey = "secret"
|
||||
cfg.Channel.Proxy.SharedPollRefresh = sharedPollDefaultProxy()
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "poll",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "shared_poll",
|
||||
SharedPoll: configtypes.SharedPollConfig{
|
||||
Mode: "versionless",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
80
internal/config/testdata/config.json
vendored
Normal file
80
internal/config/testdata/config.json
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"http_server": {
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_ca_pem": "-----BEGIN CERTIFICATE-----\nMIIEbjCCAtagAwIBAgIRAN1ZJEYl5ZNIOHsbJizQpucwDQYJKoZIhvcNAQELBQAw\ngZ8xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE6MDgGA1UECwwxZnpA\nTWFjQm9vay1Qcm8tQWxleGFuZGVyLmxvY2FsIChBbGV4YW5kZXIgRW1lbGluKTFB\nMD8GA1UEAww4bWtjZXJ0IGZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAo\nQWxleGFuZGVyIEVtZWxpbikwHhcNMjIwNjE2MDYxOTM0WhcNMjQwOTE2MDYxOTM0\nWjBlMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxOjA4\nBgNVBAsMMWZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAoQWxleGFuZGVy\nIEVtZWxpbikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDLCNVIle5k\nlfRtzjHe9sEo8zU9pqXfK9fxc2PZqfd6HVDVWyrOHNv9zWV8awEEgwX2kg+sY4ch\nuKmNdD19UWxLovCMkA92gKhzJoPPBMlVRtSA9QWNw4cXXB25KErPPyBXyyFA13X/\n6N408I26Aj6ewA0WLISkNgiCddUo31FygTNH4yWXF+F+lol0EJhG+K3E8diYub4P\n1Ul417sQ/1FxcoGo43fGl8j4y6wCnBQkSNaQCr1vvNEzdmiIYF02a51Efdb3PrSu\n90nJJBbFQxNhpcl98tLRF5t3wZJ+R2Xy4xPUZYwNNWTdICqW7a4bfD4foByp85kr\nu44kw7laXghhAgMBAAGjXjBcMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr\nBgEFBQcDATAfBgNVHSMEGDAWgBSMh55IrbevJTB4kiFUXsarEAIjXjAUBgNVHREE\nDTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggGBAG9yTMOybS6ike/UoIrj\nxLE3a9nPuFdt9anS0XgYicCYNFLc5H6MUXubsqBz30zigFbNP/FR2DKvIP+1cySP\nDKqnimTdxZWjzT9d0YHEYcD971yk/whXmKOcla2VmYMuPmUr6M3BmUmYcoWve/ML\nnc8qKJ+CsM80zxFSRbqCVqgPfNDzPHqGbJmOn0KbLPWzkUsIbii/O4IjqycJiDMS\nCyuat2Q8TYGiRhDJnouD/semDtqaIGGT77/5QLoEhFRwRKbOfgTT0hjLgTbeKPrx\nQKARxjVC/QF59nhdf+je/BgrF7jfR1UuCSxwl0xg2Ub2JB5A77efWEoQh2fuSgZk\nmVTZqDnfGvfYcGE9oiAMl21DimEAdYFSAUTtVI6T0S8BagN3jD+FLV7+TJgPiyIO\nLz9gcDP1Zn3jIp4Vy2HawWt+8rta351L70ie9Sk6Cx5fV0slvTFteWYdm26BuKbp\nNF7OqlGSRzM2iEVaMFLqnrRwDF4bR7qwGukppEXPrsAq2Q==\n-----END CERTIFICATE-----"
|
||||
}
|
||||
},
|
||||
"engine": {
|
||||
"type": "redis",
|
||||
"redis": {
|
||||
"address": "redis:6379",
|
||||
"presence_ttl": "30s"
|
||||
}
|
||||
},
|
||||
"client": {
|
||||
"allowed_origins": ["http://localhost:3000"],
|
||||
"token": {
|
||||
"jwks_public_endpoint": "https://example.com/jwks"
|
||||
},
|
||||
"ping_interval": "12s",
|
||||
"proxy": {
|
||||
"connect": {
|
||||
"http": {
|
||||
"static_headers": {
|
||||
"X": "y"
|
||||
}
|
||||
}
|
||||
},
|
||||
"refresh": {
|
||||
"http": {
|
||||
"static_headers": [
|
||||
{
|
||||
"key": "X",
|
||||
"value": "y"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"channel": {
|
||||
"without_namespace": {
|
||||
"presence": true
|
||||
},
|
||||
"namespaces": [
|
||||
{
|
||||
"name": "ns1",
|
||||
"presence": true
|
||||
},
|
||||
{
|
||||
"name": "ns2",
|
||||
"presence": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"proxies": [
|
||||
{
|
||||
"name": "ppp"
|
||||
}
|
||||
],
|
||||
"consumers": [
|
||||
{
|
||||
"name": "kafka",
|
||||
"enabled": true,
|
||||
"type": "kafka",
|
||||
"kafka": {
|
||||
"brokers": ["localhost:9092"],
|
||||
"topics": ["postgres.public.chat_cdc"],
|
||||
"consumer_group": "centrifugo",
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"cert_pem": "xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"websocket": {
|
||||
"write_timeout": "2s"
|
||||
}
|
||||
}
|
||||
84
internal/config/testdata/config.toml
vendored
Normal file
84
internal/config/testdata/config.toml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
[http_server]
|
||||
|
||||
[http_server.tls]
|
||||
enabled = true
|
||||
server_ca_pem = """
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEbjCCAtagAwIBAgIRAN1ZJEYl5ZNIOHsbJizQpucwDQYJKoZIhvcNAQELBQAw
|
||||
gZ8xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE6MDgGA1UECwwxZnpA
|
||||
TWFjQm9vay1Qcm8tQWxleGFuZGVyLmxvY2FsIChBbGV4YW5kZXIgRW1lbGluKTFB
|
||||
MD8GA1UEAww4bWtjZXJ0IGZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAo
|
||||
QWxleGFuZGVyIEVtZWxpbikwHhcNMjIwNjE2MDYxOTM0WhcNMjQwOTE2MDYxOTM0
|
||||
WjBlMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxOjA4
|
||||
BgNVBAsMMWZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAoQWxleGFuZGVy
|
||||
IEVtZWxpbikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDLCNVIle5k
|
||||
lfRtzjHe9sEo8zU9pqXfK9fxc2PZqfd6HVDVWyrOHNv9zWV8awEEgwX2kg+sY4ch
|
||||
uKmNdD19UWxLovCMkA92gKhzJoPPBMlVRtSA9QWNw4cXXB25KErPPyBXyyFA13X/
|
||||
6N408I26Aj6ewA0WLISkNgiCddUo31FygTNH4yWXF+F+lol0EJhG+K3E8diYub4P
|
||||
1Ul417sQ/1FxcoGo43fGl8j4y6wCnBQkSNaQCr1vvNEzdmiIYF02a51Efdb3PrSu
|
||||
90nJJBbFQxNhpcl98tLRF5t3wZJ+R2Xy4xPUZYwNNWTdICqW7a4bfD4foByp85kr
|
||||
u44kw7laXghhAgMBAAGjXjBcMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr
|
||||
BgEFBQcDATAfBgNVHSMEGDAWgBSMh55IrbevJTB4kiFUXsarEAIjXjAUBgNVHREE
|
||||
DTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggGBAG9yTMOybS6ike/UoIrj
|
||||
xLE3a9nPuFdt9anS0XgYicCYNFLc5H6MUXubsqBz30zigFbNP/FR2DKvIP+1cySP
|
||||
DKqnimTdxZWjzT9d0YHEYcD971yk/whXmKOcla2VmYMuPmUr6M3BmUmYcoWve/ML
|
||||
nc8qKJ+CsM80zxFSRbqCVqgPfNDzPHqGbJmOn0KbLPWzkUsIbii/O4IjqycJiDMS
|
||||
Cyuat2Q8TYGiRhDJnouD/semDtqaIGGT77/5QLoEhFRwRKbOfgTT0hjLgTbeKPrx
|
||||
QKARxjVC/QF59nhdf+je/BgrF7jfR1UuCSxwl0xg2Ub2JB5A77efWEoQh2fuSgZk
|
||||
mVTZqDnfGvfYcGE9oiAMl21DimEAdYFSAUTtVI6T0S8BagN3jD+FLV7+TJgPiyIO
|
||||
Lz9gcDP1Zn3jIp4Vy2HawWt+8rta351L70ie9Sk6Cx5fV0slvTFteWYdm26BuKbp
|
||||
NF7OqlGSRzM2iEVaMFLqnrRwDF4bR7qwGukppEXPrsAq2Q==
|
||||
-----END CERTIFICATE-----
|
||||
"""
|
||||
|
||||
[engine]
|
||||
type = "redis"
|
||||
|
||||
[engine.redis]
|
||||
address = "redis:6379"
|
||||
presence_ttl = "30s"
|
||||
|
||||
[client]
|
||||
allowed_origins = [ "http://localhost:3000" ]
|
||||
ping_interval = "12s"
|
||||
|
||||
[client.token]
|
||||
jwks_public_endpoint = "https://example.com/jwks"
|
||||
|
||||
[client.proxy.connect.http.static_headers]
|
||||
x = "y"
|
||||
|
||||
[[client.proxy.refresh.http.static_headers]]
|
||||
key = "X"
|
||||
value = "y"
|
||||
|
||||
[channel.without_namespace]
|
||||
presence = true
|
||||
|
||||
[[channel.namespaces]]
|
||||
name = "ns1"
|
||||
presence = true
|
||||
|
||||
[[channel.namespaces]]
|
||||
name = "ns2"
|
||||
presence = false
|
||||
|
||||
[[proxies]]
|
||||
name = "ppp"
|
||||
|
||||
[[consumers]]
|
||||
name = "kafka"
|
||||
enabled = true
|
||||
type = "kafka"
|
||||
|
||||
[consumers.kafka]
|
||||
brokers = [ "localhost:9092" ]
|
||||
topics = [ "postgres.public.chat_cdc" ]
|
||||
consumer_group = "centrifugo"
|
||||
|
||||
[consumers.kafka.tls]
|
||||
enabled = true
|
||||
cert_pem = "xxx"
|
||||
|
||||
[websocket]
|
||||
write_timeout = "2s"
|
||||
77
internal/config/testdata/config.yaml
vendored
Normal file
77
internal/config/testdata/config.yaml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
http_server:
|
||||
tls:
|
||||
enabled: true
|
||||
server_ca_pem: |
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEbjCCAtagAwIBAgIRAN1ZJEYl5ZNIOHsbJizQpucwDQYJKoZIhvcNAQELBQAw
|
||||
gZ8xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE6MDgGA1UECwwxZnpA
|
||||
TWFjQm9vay1Qcm8tQWxleGFuZGVyLmxvY2FsIChBbGV4YW5kZXIgRW1lbGluKTFB
|
||||
MD8GA1UEAww4bWtjZXJ0IGZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAo
|
||||
QWxleGFuZGVyIEVtZWxpbikwHhcNMjIwNjE2MDYxOTM0WhcNMjQwOTE2MDYxOTM0
|
||||
WjBlMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxOjA4
|
||||
BgNVBAsMMWZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAoQWxleGFuZGVy
|
||||
IEVtZWxpbikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDLCNVIle5k
|
||||
lfRtzjHe9sEo8zU9pqXfK9fxc2PZqfd6HVDVWyrOHNv9zWV8awEEgwX2kg+sY4ch
|
||||
uKmNdD19UWxLovCMkA92gKhzJoPPBMlVRtSA9QWNw4cXXB25KErPPyBXyyFA13X/
|
||||
6N408I26Aj6ewA0WLISkNgiCddUo31FygTNH4yWXF+F+lol0EJhG+K3E8diYub4P
|
||||
1Ul417sQ/1FxcoGo43fGl8j4y6wCnBQkSNaQCr1vvNEzdmiIYF02a51Efdb3PrSu
|
||||
90nJJBbFQxNhpcl98tLRF5t3wZJ+R2Xy4xPUZYwNNWTdICqW7a4bfD4foByp85kr
|
||||
u44kw7laXghhAgMBAAGjXjBcMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr
|
||||
BgEFBQcDATAfBgNVHSMEGDAWgBSMh55IrbevJTB4kiFUXsarEAIjXjAUBgNVHREE
|
||||
DTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggGBAG9yTMOybS6ike/UoIrj
|
||||
xLE3a9nPuFdt9anS0XgYicCYNFLc5H6MUXubsqBz30zigFbNP/FR2DKvIP+1cySP
|
||||
DKqnimTdxZWjzT9d0YHEYcD971yk/whXmKOcla2VmYMuPmUr6M3BmUmYcoWve/ML
|
||||
nc8qKJ+CsM80zxFSRbqCVqgPfNDzPHqGbJmOn0KbLPWzkUsIbii/O4IjqycJiDMS
|
||||
Cyuat2Q8TYGiRhDJnouD/semDtqaIGGT77/5QLoEhFRwRKbOfgTT0hjLgTbeKPrx
|
||||
QKARxjVC/QF59nhdf+je/BgrF7jfR1UuCSxwl0xg2Ub2JB5A77efWEoQh2fuSgZk
|
||||
mVTZqDnfGvfYcGE9oiAMl21DimEAdYFSAUTtVI6T0S8BagN3jD+FLV7+TJgPiyIO
|
||||
Lz9gcDP1Zn3jIp4Vy2HawWt+8rta351L70ie9Sk6Cx5fV0slvTFteWYdm26BuKbp
|
||||
NF7OqlGSRzM2iEVaMFLqnrRwDF4bR7qwGukppEXPrsAq2Q==
|
||||
-----END CERTIFICATE-----
|
||||
engine:
|
||||
type: redis
|
||||
redis:
|
||||
address: redis:6379
|
||||
presence_ttl: 30s
|
||||
client:
|
||||
allowed_origins:
|
||||
- http://localhost:3000
|
||||
token:
|
||||
jwks_public_endpoint: https://example.com/jwks
|
||||
ping_interval: 12s
|
||||
proxy:
|
||||
connect:
|
||||
http:
|
||||
static_headers:
|
||||
x: "y"
|
||||
refresh:
|
||||
http:
|
||||
static_headers:
|
||||
- key: X
|
||||
value: y
|
||||
channel:
|
||||
without_namespace:
|
||||
presence: true
|
||||
namespaces:
|
||||
- name: ns1
|
||||
presence: true
|
||||
- name: ns2
|
||||
presence: false
|
||||
proxies:
|
||||
- name: ppp
|
||||
consumers:
|
||||
- name: kafka
|
||||
enabled: true
|
||||
type: kafka
|
||||
kafka:
|
||||
brokers:
|
||||
- localhost:9092
|
||||
topics:
|
||||
- postgres.public.chat_cdc
|
||||
consumer_group: centrifugo
|
||||
tls:
|
||||
enabled: true
|
||||
cert_pem: xxx
|
||||
websocket:
|
||||
write_timeout: 2s
|
||||
606
internal/config/validate.go
Normal file
606
internal/config/validate.go
Normal file
@@ -0,0 +1,606 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/tools"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
)
|
||||
|
||||
var knownBrokers = []string{"memory", "nats", "redis", "redisnats", "postgres"}
|
||||
|
||||
// Validate validates config and returns error if problems found.
|
||||
func (c Config) Validate() error {
|
||||
if c.HTTP.H2CExternal && (c.HTTP.InternalPort == "" || strconv.Itoa(c.HTTP.Port) == c.HTTP.InternalPort) {
|
||||
return fmt.Errorf("external_h2c requires custom separate internal_port to be configured")
|
||||
}
|
||||
if c.HTTP.TLS.Enabled && c.HTTP.H2CExternal {
|
||||
return fmt.Errorf("external_h2c cannot be used together with enabled TLS for external HTTP server")
|
||||
}
|
||||
|
||||
if !slices.Contains(knownBrokers, c.Broker.Type) {
|
||||
return fmt.Errorf("unknown broker: %s", c.Broker.Type)
|
||||
}
|
||||
|
||||
if err := validateTokens(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Client.Proxy.Connect.Enabled {
|
||||
if err := validateProxy("default", c.Client.Proxy.Connect.Proxy); err != nil {
|
||||
return fmt.Errorf("in client.proxy.connect: %v", err)
|
||||
}
|
||||
}
|
||||
if c.Client.Proxy.Refresh.Enabled {
|
||||
if err := validateProxy("default", c.Client.Proxy.Refresh.Proxy); err != nil {
|
||||
return fmt.Errorf("in client.proxy.refresh: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateSecondPrecisionDuration(c.Channel.HistoryMetaTTL); err != nil {
|
||||
return fmt.Errorf("in channel.history_meta_ttl: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Contains([]string{"", configtypes.PublicationDataFormatJSON, configtypes.PublicationDataFormatJSONObject, configtypes.PublicationDataFormatBinary}, c.Channel.PublicationDataFormat) {
|
||||
return fmt.Errorf("unknown channel.publication_data_format: \"%s\"", c.Channel.PublicationDataFormat)
|
||||
}
|
||||
|
||||
if err := validateCodeToUniDisconnectTransforms(c.Client.ConnectCodeToUnidirectionalDisconnect.Transforms); err != nil {
|
||||
return fmt.Errorf("in client.connect_code_to_unidirectional_disconnect: %v", err)
|
||||
}
|
||||
|
||||
var proxyNames []string
|
||||
for _, p := range c.Proxies {
|
||||
if slices.Contains(proxyNames, p.Name) {
|
||||
return fmt.Errorf("duplicate proxy name in proxies: %s", p.Name)
|
||||
}
|
||||
if err := validateProxy(p.Name, p.Proxy); err != nil {
|
||||
return fmt.Errorf("in proxy %s: %v", p.Name, err)
|
||||
}
|
||||
proxyNames = append(proxyNames, p.Name)
|
||||
}
|
||||
if slices.Contains(proxyNames, DefaultProxyName) {
|
||||
return fmt.Errorf("proxy name %s is reserved", DefaultProxyName)
|
||||
}
|
||||
proxyNames = append(proxyNames, DefaultProxyName) // channel options can use default proxy name.
|
||||
|
||||
if err := validateChannelOptions(c.Channel.WithoutNamespace, c.Channel.HistoryMetaTTL, proxyNames, c); err != nil {
|
||||
return fmt.Errorf("in channel.without_namespace: %v", err)
|
||||
}
|
||||
|
||||
if err := validateRpcOptions(c.RPC.WithoutNamespace, proxyNames); err != nil {
|
||||
return fmt.Errorf("in rpc.without_namespace: %v", err)
|
||||
}
|
||||
|
||||
usePersonalChannel := c.Client.SubscribeToUserPersonalChannel.Enabled
|
||||
personalChannelNamespace := c.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace
|
||||
personalSingleConnection := c.Client.SubscribeToUserPersonalChannel.SingleConnection
|
||||
var validPersonalChannelNamespace bool
|
||||
if !usePersonalChannel || personalChannelNamespace == "" {
|
||||
validPersonalChannelNamespace = true
|
||||
if personalSingleConnection && !c.Channel.WithoutNamespace.Presence {
|
||||
return fmt.Errorf("presence must be enabled on top level to maintain single connection")
|
||||
}
|
||||
}
|
||||
|
||||
nss := make([]string, 0, len(c.Channel.Namespaces))
|
||||
for _, n := range c.Channel.Namespaces {
|
||||
if slices.Contains(nss, n.Name) {
|
||||
return fmt.Errorf("namespace name must be unique: %s", n.Name)
|
||||
}
|
||||
if err := validateNamespace(n, c.Channel.HistoryMetaTTL, proxyNames, c); err != nil {
|
||||
return fmt.Errorf("namespace %s: %v", n.Name, err)
|
||||
}
|
||||
if n.Name == personalChannelNamespace {
|
||||
validPersonalChannelNamespace = true
|
||||
if personalSingleConnection && !n.Presence {
|
||||
return fmt.Errorf("presence must be enabled for namespace %s to maintain single connection", n.Name)
|
||||
}
|
||||
}
|
||||
nss = append(nss, n.Name)
|
||||
}
|
||||
|
||||
if !validPersonalChannelNamespace {
|
||||
return fmt.Errorf("namespace for user personal channel not found: %s", personalChannelNamespace)
|
||||
}
|
||||
|
||||
// Validate that map presence channel prefix references point to existing namespaces with map type.
|
||||
boundary := c.Channel.NamespaceBoundary
|
||||
if boundary == "" {
|
||||
boundary = ":"
|
||||
}
|
||||
for _, n := range c.Channel.Namespaces {
|
||||
if n.MapClientsPresenceChannelPrefix != "" {
|
||||
targetNs := strings.SplitN(n.MapClientsPresenceChannelPrefix+"x", boundary, 2)[0]
|
||||
if !slices.Contains(nss, targetNs) {
|
||||
return fmt.Errorf("namespace %s: map_clients_presence_channel_prefix %q resolves to namespace %q which does not exist", n.Name, n.MapClientsPresenceChannelPrefix, targetNs)
|
||||
}
|
||||
}
|
||||
if n.MapUsersPresenceChannelPrefix != "" {
|
||||
targetNs := strings.SplitN(n.MapUsersPresenceChannelPrefix+"x", boundary, 2)[0]
|
||||
if !slices.Contains(nss, targetNs) {
|
||||
return fmt.Errorf("namespace %s: map_users_presence_channel_prefix %q resolves to namespace %q which does not exist", n.Name, n.MapUsersPresenceChannelPrefix, targetNs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rpcNss := make([]string, 0, len(c.RPC.Namespaces))
|
||||
for _, n := range c.RPC.Namespaces {
|
||||
if slices.Contains(rpcNss, n.Name) {
|
||||
return fmt.Errorf("rpc namespace name must be unique: %s", n.Name)
|
||||
}
|
||||
if err := validateRpcNamespace(n, proxyNames); err != nil {
|
||||
return fmt.Errorf("rpc namespace %s: %v", n.Name, err)
|
||||
}
|
||||
rpcNss = append(rpcNss, n.Name)
|
||||
}
|
||||
|
||||
var consumerNames []string
|
||||
for _, config := range c.Consumers {
|
||||
if !consumerNameRe.Match([]byte(config.Name)) {
|
||||
return fmt.Errorf("invalid consumer name: %s, must match %s regular expression", config.Name, consumerNamePattern)
|
||||
}
|
||||
if slices.Contains(consumerNames, config.Name) {
|
||||
return fmt.Errorf("invalid consumer name: %s, must be unique", config.Name)
|
||||
}
|
||||
if !slices.Contains(configtypes.KnownConsumerTypes, config.Type) {
|
||||
return fmt.Errorf("unknown consumer type: %s", config.Type)
|
||||
}
|
||||
if config.Enabled {
|
||||
var err error
|
||||
switch config.Type {
|
||||
case configtypes.ConsumerTypeKafka:
|
||||
err = config.Kafka.Validate()
|
||||
case configtypes.ConsumerTypePostgres:
|
||||
err = config.Postgres.Validate()
|
||||
case configtypes.ConsumerTypeGooglePubSub:
|
||||
err = config.GooglePubSub.Validate()
|
||||
case configtypes.ConsumerTypeRedisStream:
|
||||
err = config.RedisStream.Validate()
|
||||
case configtypes.ConsumerTypeNatsJetStream:
|
||||
err = config.NatsJetStream.Validate()
|
||||
case configtypes.ConsumerTypeAwsSqs:
|
||||
err = config.AwsSqs.Validate()
|
||||
case configtypes.ConsumerTypeAzureServiceBus:
|
||||
err = config.AzureServiceBus.Validate()
|
||||
default:
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("in consumer %s (%s): %w", config.Name, config.Type, err)
|
||||
}
|
||||
}
|
||||
consumerNames = append(consumerNames, config.Name)
|
||||
}
|
||||
|
||||
if err := validateConnectCodeTransforms(c.UniSSE.ConnectCodeToHTTPResponse.Transforms); err != nil {
|
||||
return fmt.Errorf("in uni_sse.connect_code_to_http_response.transforms: %v", err)
|
||||
}
|
||||
if err := validateConnectCodeTransforms(c.UniHTTPStream.ConnectCodeToHTTPResponse.Transforms); err != nil {
|
||||
return fmt.Errorf("in uni_http_stream.connect_code_to_http_response.transforms: %v", err)
|
||||
}
|
||||
|
||||
// Map broker validation.
|
||||
var knownMapBrokers = []string{"memory", "redis", "postgres"}
|
||||
if !slices.Contains(knownMapBrokers, c.MapBroker.Type) {
|
||||
return fmt.Errorf("unknown map broker type: %s", c.MapBroker.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var namePattern = "^[-a-zA-Z0-9_.]{2,}$"
|
||||
var nameRe = regexp.MustCompile(namePattern)
|
||||
|
||||
func validateNamespace(ns configtypes.ChannelNamespace, globalHistoryMetaTTL configtypes.Duration, proxyNames []string, cfg Config) error {
|
||||
name := ns.Name
|
||||
match := nameRe.MatchString(name)
|
||||
if !match {
|
||||
return fmt.Errorf("invalid namespace name – %s (must match %s regular expression)", name, namePattern)
|
||||
}
|
||||
if err := validateChannelOptions(ns.ChannelOptions, globalHistoryMetaTTL, proxyNames, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRpcNamespace(ns configtypes.RpcNamespace, rpcProxyNames []string) error {
|
||||
name := ns.Name
|
||||
match := nameRe.MatchString(name)
|
||||
if !match {
|
||||
return fmt.Errorf("invalid rpc namespace name – %s (must match %s regular expression)", name, namePattern)
|
||||
}
|
||||
if err := validateRpcOptions(ns.RpcOptions, rpcProxyNames); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateChannelOptions(c configtypes.ChannelOptions, globalHistoryMetaTTL configtypes.Duration, proxyNames []string, cfg Config) error {
|
||||
if err := validateSecondPrecisionDuration(c.HistoryTTL); err != nil {
|
||||
return fmt.Errorf("in history_ttl: %v", err)
|
||||
}
|
||||
if err := validateSecondPrecisionDuration(c.HistoryMetaTTL); err != nil {
|
||||
return fmt.Errorf("in history_meta_ttl: %v", err)
|
||||
}
|
||||
if (c.HistorySize != 0 && c.HistoryTTL == 0) || (c.HistorySize == 0 && c.HistoryTTL != 0) {
|
||||
return errors.New("both history size and history ttl required for history")
|
||||
}
|
||||
historyMetaTTL := globalHistoryMetaTTL
|
||||
if c.HistoryMetaTTL != 0 {
|
||||
historyMetaTTL = c.HistoryMetaTTL
|
||||
}
|
||||
if historyMetaTTL < c.HistoryTTL {
|
||||
return fmt.Errorf("history ttl (%s) can not be greater than history meta ttl (%s)", c.HistoryTTL, historyMetaTTL)
|
||||
}
|
||||
if c.ForceRecovery && (c.HistorySize == 0 || c.HistoryTTL == 0) {
|
||||
return errors.New("both history size and history ttl required for recovery")
|
||||
}
|
||||
if c.AutoCacheRecover && (!c.ForceRecovery || c.ForceRecoveryMode != "cache") {
|
||||
return errors.New("auto_cache_recover requires force_recovery and force_recovery_mode set to cache")
|
||||
}
|
||||
if c.ChannelRegex != "" {
|
||||
if _, err := regexp.Compile(c.ChannelRegex); err != nil {
|
||||
return fmt.Errorf("invalid channel regex %s: %w", c.ChannelRegex, err)
|
||||
}
|
||||
}
|
||||
if (c.SubscribeStreamProxyEnabled) && (c.SubscribeProxyEnabled || c.PublishProxyEnabled || c.SubRefreshProxyEnabled) {
|
||||
return fmt.Errorf("can't use subscribe stream proxy together with subscribe, publish or sub refresh proxies")
|
||||
}
|
||||
if len(c.AllowedDeltaTypes) > 0 {
|
||||
for _, dt := range c.AllowedDeltaTypes {
|
||||
if !slices.Contains([]centrifuge.DeltaType{centrifuge.DeltaTypeFossil}, dt) {
|
||||
return fmt.Errorf("unknown allowed delta type: \"%s\"", dt)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !slices.Contains([]string{"", "stream", "cache"}, c.ForceRecoveryMode) {
|
||||
return fmt.Errorf("unknown recovery mode: \"%s\"", c.ForceRecoveryMode)
|
||||
}
|
||||
|
||||
if !slices.Contains([]string{"", configtypes.PublicationDataFormatJSON, configtypes.PublicationDataFormatJSONObject, configtypes.PublicationDataFormatBinary}, c.PublicationDataFormat) {
|
||||
return fmt.Errorf("unknown publication_data_format: \"%s\"", c.PublicationDataFormat)
|
||||
}
|
||||
|
||||
if c.SubscribeProxyName != "" && !slices.Contains(proxyNames, c.SubscribeProxyName) {
|
||||
return fmt.Errorf("subscribe proxy with name \"%s\" not found", c.SubscribeProxyName)
|
||||
}
|
||||
if c.SubscribeProxyEnabled && c.SubscribeProxyName == DefaultProxyName {
|
||||
if err := validateProxy("default", cfg.Channel.Proxy.Subscribe); err != nil {
|
||||
return fmt.Errorf("in channel.proxy.subscribe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if c.PublishProxyName != "" && !slices.Contains(proxyNames, c.PublishProxyName) {
|
||||
return fmt.Errorf("publish proxy with name \"%s\" not found", c.PublishProxyName)
|
||||
}
|
||||
if c.PublishProxyEnabled && c.PublishProxyName == DefaultProxyName {
|
||||
if err := validateProxy("default", cfg.Channel.Proxy.Publish); err != nil {
|
||||
return fmt.Errorf("in channel.proxy.publish: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if c.SubRefreshProxyName != "" && !slices.Contains(proxyNames, c.SubRefreshProxyName) {
|
||||
return fmt.Errorf("sub refresh proxy with name \"%s\" not found", c.SubRefreshProxyName)
|
||||
}
|
||||
if c.SubRefreshProxyEnabled && c.SubRefreshProxyName == DefaultProxyName {
|
||||
if err := validateProxy("default", cfg.Channel.Proxy.SubRefresh); err != nil {
|
||||
return fmt.Errorf("in channel.proxy.sub_refresh: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if c.SubscribeStreamProxyName != "" && !slices.Contains(proxyNames, c.SubscribeStreamProxyName) {
|
||||
return fmt.Errorf("subscribe stream proxy with name \"%s\" not found", c.SubscribeStreamProxyName)
|
||||
}
|
||||
if c.SubscribeStreamProxyEnabled && c.SubscribeStreamProxyName == DefaultProxyName {
|
||||
if err := validateProxy("default", cfg.Channel.Proxy.SubscribeStream); err != nil {
|
||||
return fmt.Errorf("in channel.proxy.subscribe_stream: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Map.PublishProxyName != "" && !slices.Contains(proxyNames, c.Map.PublishProxyName) {
|
||||
return fmt.Errorf("map publish proxy with name \"%s\" not found", c.Map.PublishProxyName)
|
||||
}
|
||||
if c.Map.PublishProxyEnabled && c.Map.PublishProxyName == DefaultProxyName {
|
||||
if err := validateProxy("default", cfg.Channel.Proxy.MapPublish); err != nil {
|
||||
return fmt.Errorf("in channel.proxy.map_publish: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Map.RemoveProxyName != "" && !slices.Contains(proxyNames, c.Map.RemoveProxyName) {
|
||||
return fmt.Errorf("map remove proxy with name \"%s\" not found", c.Map.RemoveProxyName)
|
||||
}
|
||||
if c.Map.RemoveProxyEnabled && c.Map.RemoveProxyName == DefaultProxyName {
|
||||
if err := validateProxy("default", cfg.Channel.Proxy.MapRemove); err != nil {
|
||||
return fmt.Errorf("in channel.proxy.map_remove: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Map.ClientKey != "" && !slices.Contains([]string{"client_id", "user_id"}, c.Map.ClientKey) {
|
||||
return fmt.Errorf("unknown map.client_key: %q (valid: \"client_id\", \"user_id\")", c.Map.ClientKey)
|
||||
}
|
||||
// map.client_key and the publish/remove proxy both decide the final key.
|
||||
// When a proxy is configured, it is solely responsible for keying (it may
|
||||
// echo the client's key or override). Allowing both at once silently lets
|
||||
// the proxy win over client_key, which surprises users who expect
|
||||
// client_key to force the keying. Reject the combination at config-load,
|
||||
// naming the actually-conflicting proxy field.
|
||||
if c.Map.ClientKey != "" && c.Map.PublishProxyEnabled {
|
||||
return fmt.Errorf("map.client_key is not compatible with map.publish_proxy_enabled: the proxy is responsible for keying when enabled")
|
||||
}
|
||||
if c.Map.ClientKey != "" && c.Map.RemoveProxyEnabled {
|
||||
return fmt.Errorf("map.client_key is not compatible with map.remove_proxy_enabled: the proxy is responsible for keying when enabled")
|
||||
}
|
||||
|
||||
if c.SubscriptionType != "" && !slices.Contains([]string{"stream", "map", "map_clients", "map_users", "shared_poll"}, c.SubscriptionType) {
|
||||
return fmt.Errorf("unknown subscription_type: %q", c.SubscriptionType)
|
||||
}
|
||||
if c.SubscriptionType == "shared_poll" {
|
||||
if cfg.SharedPoll.HMACSecretKey == "" {
|
||||
return fmt.Errorf("shared_poll.hmac_secret_key is required when subscription_type is \"shared_poll\"")
|
||||
}
|
||||
if c.SharedPoll.ProxyName != "" && !slices.Contains(proxyNames, c.SharedPoll.ProxyName) {
|
||||
return fmt.Errorf("shared poll proxy with name %q not found", c.SharedPoll.ProxyName)
|
||||
}
|
||||
sharedPollProxyName := c.SharedPoll.ProxyName
|
||||
if sharedPollProxyName == "" {
|
||||
sharedPollProxyName = DefaultProxyName
|
||||
}
|
||||
if sharedPollProxyName == DefaultProxyName {
|
||||
if err := validateProxy("default", cfg.Channel.Proxy.SharedPollRefresh); err != nil {
|
||||
return fmt.Errorf("in channel.proxy.shared_poll_refresh: %v", err)
|
||||
}
|
||||
}
|
||||
if !slices.Contains([]string{"", "versionless", "versioned"}, c.SharedPoll.Mode) {
|
||||
return fmt.Errorf("invalid shared_poll mode: %q", c.SharedPoll.Mode)
|
||||
}
|
||||
if c.SharedPoll.PublishEnabled && (c.SharedPoll.Mode == "" || c.SharedPoll.Mode == "versionless") {
|
||||
return fmt.Errorf("shared_poll publish_enabled is incompatible with versionless mode (requires explicit versions)")
|
||||
}
|
||||
}
|
||||
if c.Map.Mode != "" && !slices.Contains([]string{"ephemeral", "recoverable", "persistent"}, c.Map.Mode) {
|
||||
return fmt.Errorf("unknown map.mode: %q (valid: \"ephemeral\", \"recoverable\", \"persistent\")", c.Map.Mode)
|
||||
}
|
||||
hasMapType := c.SubscriptionType == "map" || c.SubscriptionType == "map_clients" || c.SubscriptionType == "map_users"
|
||||
if hasMapType {
|
||||
if c.Map.Mode == "" {
|
||||
return fmt.Errorf("map.mode is required when subscription_type is a map type")
|
||||
}
|
||||
}
|
||||
mapHasExpiry := c.Map.Mode == "ephemeral" || c.Map.Mode == "recoverable"
|
||||
if mapHasExpiry {
|
||||
if c.Map.KeyTTL == 0 {
|
||||
return fmt.Errorf("map.key_ttl is required when map.mode is %q", c.Map.Mode)
|
||||
}
|
||||
if c.Map.KeyTTL.ToDuration() < 0 {
|
||||
return fmt.Errorf("map.key_ttl must be positive")
|
||||
}
|
||||
}
|
||||
if c.Map.Mode == "persistent" && c.Map.KeyTTL != 0 {
|
||||
return fmt.Errorf("map.key_ttl must not be set when map.mode is \"persistent\" (entries don't expire)")
|
||||
}
|
||||
// map_clients and map_users rely on TTL as the cleanup fallback: map_clients
|
||||
// uses MapRemove on disconnect with TTL as the safety net for transient
|
||||
// remove failures; map_users is *only* cleaned up by TTL (no remove on
|
||||
// disconnect by design). Persistent mode disables TTL, which means stale
|
||||
// presence entries can linger indefinitely. Refuse the combination.
|
||||
if c.Map.Mode == "persistent" && (c.SubscriptionType == "map_clients" || c.SubscriptionType == "map_users") {
|
||||
return fmt.Errorf("map.mode %q is not allowed with subscription_type %q (presence entries require TTL-based cleanup; use \"ephemeral\" or \"recoverable\")", c.Map.Mode, c.SubscriptionType)
|
||||
}
|
||||
if c.Map.Mode == "ephemeral" {
|
||||
if c.Map.StreamSize > 0 {
|
||||
return fmt.Errorf("map.stream_size must be 0 for map.mode \"ephemeral\"")
|
||||
}
|
||||
if c.Map.StreamTTL != 0 {
|
||||
return fmt.Errorf("map.stream_ttl must be 0 for map.mode \"ephemeral\"")
|
||||
}
|
||||
if c.Map.MetaTTL != 0 {
|
||||
return fmt.Errorf("map.meta_ttl must be 0 for map.mode \"ephemeral\"")
|
||||
}
|
||||
}
|
||||
mapHasStream := c.Map.Mode == "recoverable" || c.Map.Mode == "persistent"
|
||||
if mapHasStream {
|
||||
if c.Map.StreamSize < 0 {
|
||||
return fmt.Errorf("map.stream_size must be non-negative")
|
||||
}
|
||||
if c.Map.StreamTTL.ToDuration() < 0 {
|
||||
return fmt.Errorf("map.stream_ttl must be non-negative")
|
||||
}
|
||||
if c.Map.MetaTTL.ToDuration() < 0 {
|
||||
return fmt.Errorf("map.meta_ttl must be non-negative")
|
||||
}
|
||||
if c.Map.MetaTTL != 0 {
|
||||
effectiveStreamTTL := c.Map.StreamTTL.ToDuration()
|
||||
if effectiveStreamTTL == 0 {
|
||||
effectiveStreamTTL = time.Minute
|
||||
}
|
||||
if c.Map.MetaTTL.ToDuration() < effectiveStreamTTL {
|
||||
return fmt.Errorf("map.meta_ttl (%s) must be >= map.stream_ttl (%s) (metadata must outlive stream)", c.Map.MetaTTL, configtypes.Duration(effectiveStreamTTL))
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.Map.RemoveClientOnUnsubscribe && !hasMapType {
|
||||
return fmt.Errorf("map.remove_client_on_unsubscribe requires subscription_type to be a map type")
|
||||
}
|
||||
|
||||
// Map presence prefixes must resolve to a namespace whose subscription_type
|
||||
// is the matching map-presence flavour. Otherwise publishes to those
|
||||
// channels would silently fail at runtime.
|
||||
if c.MapClientsPresenceChannelPrefix != "" {
|
||||
if err := validatePresenceChannelPrefix(cfg, "map_clients_presence_channel_prefix", c.MapClientsPresenceChannelPrefix, "map_clients"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.MapUsersPresenceChannelPrefix != "" {
|
||||
if err := validatePresenceChannelPrefix(cfg, "map_users_presence_channel_prefix", c.MapUsersPresenceChannelPrefix, "map_users"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Map page-size invariants. Zero means "use default" — the runtime path
|
||||
// fills in sensible values — but any non-zero combination must satisfy
|
||||
// min <= default <= max so clamping never produces surprising results.
|
||||
if c.Map.MinPageSize < 0 {
|
||||
return fmt.Errorf("map.min_page_size must not be negative")
|
||||
}
|
||||
if c.Map.MaxPageSize < 0 {
|
||||
return fmt.Errorf("map.max_page_size must not be negative")
|
||||
}
|
||||
if c.Map.DefaultPageSize < 0 {
|
||||
return fmt.Errorf("map.default_page_size must not be negative")
|
||||
}
|
||||
if c.Map.MinPageSize > 0 && c.Map.MaxPageSize > 0 && c.Map.MinPageSize > c.Map.MaxPageSize {
|
||||
return fmt.Errorf("map.min_page_size (%d) must not exceed map.max_page_size (%d)", c.Map.MinPageSize, c.Map.MaxPageSize)
|
||||
}
|
||||
if c.Map.DefaultPageSize > 0 && c.Map.MaxPageSize > 0 && c.Map.DefaultPageSize > c.Map.MaxPageSize {
|
||||
return fmt.Errorf("map.default_page_size (%d) must not exceed map.max_page_size (%d)", c.Map.DefaultPageSize, c.Map.MaxPageSize)
|
||||
}
|
||||
if c.Map.DefaultPageSize > 0 && c.Map.MinPageSize > 0 && c.Map.DefaultPageSize < c.Map.MinPageSize {
|
||||
return fmt.Errorf("map.default_page_size (%d) must not be below map.min_page_size (%d)", c.Map.DefaultPageSize, c.Map.MinPageSize)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePresenceChannelPrefix checks that prefix, when prepended to a
|
||||
// channel name, resolves under the configured NamespaceBoundary to a namespace
|
||||
// whose subscription_type is `expectedType`. Caught at config-load so typos
|
||||
// in the prefix don't surface only when the first map_publish fails.
|
||||
func validatePresenceChannelPrefix(cfg Config, fieldName, prefix, expectedType string) error {
|
||||
boundary := cfg.Channel.NamespaceBoundary
|
||||
var nsName string
|
||||
if boundary != "" {
|
||||
idx := strings.Index(prefix, boundary)
|
||||
if idx >= 0 {
|
||||
nsName = prefix[:idx]
|
||||
}
|
||||
}
|
||||
opts, ok, err := channelOpts(&cfg, nsName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %v", fieldName, err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%s %q: namespace %q does not exist", fieldName, prefix, nsName)
|
||||
}
|
||||
if opts.SubscriptionType != expectedType {
|
||||
return fmt.Errorf("%s %q: target namespace %q must have subscription_type=%q (got %q)", fieldName, prefix, nsName, expectedType, opts.SubscriptionType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRpcOptions(opts configtypes.RpcOptions, rpcProxyNames []string) error {
|
||||
if opts.ProxyName != "" && !slices.Contains(rpcProxyNames, opts.ProxyName) {
|
||||
return fmt.Errorf("proxy %s not found for rpc", opts.ProxyName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var proxyNamePattern = "^[-a-zA-Z0-9_.]{2,}$"
|
||||
var proxyNameRe = regexp.MustCompile(proxyNamePattern)
|
||||
|
||||
var consumerNamePattern = "^[a-zA-Z0-9_]{2,}$"
|
||||
var consumerNameRe = regexp.MustCompile(consumerNamePattern)
|
||||
|
||||
func validateSecondPrecisionDuration(duration configtypes.Duration) error {
|
||||
if duration > 0 && duration.ToDuration()%time.Second != 0 {
|
||||
return fmt.Errorf("malformed duration %s, sub-second precision is not supported for this key", duration)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProxy(name string, p configtypes.Proxy) error {
|
||||
if !proxyNameRe.MatchString(name) {
|
||||
return fmt.Errorf("invalid proxy name: %s, must match %s regular expression", name, proxyNamePattern)
|
||||
}
|
||||
if p.Timeout == 0 {
|
||||
return errors.New("timeout not set")
|
||||
}
|
||||
if p.Endpoint == "" {
|
||||
return errors.New("endpoint not set")
|
||||
}
|
||||
if err := validateStatusTransforms(p.ProxyCommon.HTTP.StatusToCodeTransforms); err != nil {
|
||||
return fmt.Errorf("in status_to_code_transforms: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Now Centrifugo uses https://github.com/tidwall/gjson to extract custom claims from JWT. So technically
|
||||
// we could support extracting from nested objects using dot syntax, like "centrifugo.user". But for now
|
||||
// not using this feature to keep things simple until necessary.
|
||||
var customClaimRe = regexp.MustCompile("^[a-zA-Z_]+$")
|
||||
|
||||
func validateTokens(cfg Config) error {
|
||||
if cfg.Client.Token.UserIDClaim != "" {
|
||||
if !customClaimRe.MatchString(cfg.Client.Token.UserIDClaim) {
|
||||
return fmt.Errorf("invalid token custom user ID claim: %s, must match %s regular expression", cfg.Client.Token.UserIDClaim, customClaimRe.String())
|
||||
}
|
||||
}
|
||||
if cfg.Client.SubscriptionToken.UserIDClaim != "" {
|
||||
if !customClaimRe.MatchString(cfg.Client.SubscriptionToken.UserIDClaim) {
|
||||
return fmt.Errorf("invalid subscription token custom user ID claim: %s, must match %s regular expression", cfg.Client.SubscriptionToken.UserIDClaim, customClaimRe.String())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStatusTransforms(transforms []configtypes.HttpStatusToCodeTransform) error {
|
||||
for i, transform := range transforms {
|
||||
if transform.StatusCode == 0 {
|
||||
return fmt.Errorf("status_code should be set in status_to_code_transforms[%d]", i)
|
||||
}
|
||||
if transform.ToDisconnect.Code == 0 && transform.ToError.Code == 0 {
|
||||
return fmt.Errorf("no error or disconnect code set in status_to_code_transforms[%d]", i)
|
||||
}
|
||||
if transform.ToDisconnect.Code > 0 && transform.ToError.Code > 0 {
|
||||
return fmt.Errorf("only error or disconnect code can be set in status_to_code_transforms[%d], but not both", i)
|
||||
}
|
||||
if !tools.IsASCII(transform.ToDisconnect.Reason) {
|
||||
return fmt.Errorf("status_to_code_transforms[%d] disconnect reason must be ASCII", i)
|
||||
}
|
||||
if !tools.IsASCII(transform.ToError.Message) {
|
||||
return fmt.Errorf("status_to_code_transforms[%d] error message must be ASCII", i)
|
||||
}
|
||||
const reasonOrMessageMaxLength = 123 // limit comes from WebSocket close reason length limit. See https://datatracker.ietf.org/doc/html/rfc6455.
|
||||
if len(transform.ToError.Message) > reasonOrMessageMaxLength {
|
||||
return fmt.Errorf("status_to_code_transforms[%d] item error message can be up to %d characters long", i, reasonOrMessageMaxLength)
|
||||
}
|
||||
if len(transform.ToDisconnect.Reason) > reasonOrMessageMaxLength {
|
||||
return fmt.Errorf("status_to_code_transforms[%d] disconnect reason can be up to %d characters long", i, reasonOrMessageMaxLength)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConnectCodeTransforms(transforms []configtypes.ConnectCodeToHTTPResponseTransform) error {
|
||||
for i, transform := range transforms {
|
||||
if transform.Code == 0 {
|
||||
return fmt.Errorf("code should be set in connect_code_to_http_response.transforms[%d]", i)
|
||||
}
|
||||
if transform.To.StatusCode == 0 {
|
||||
return fmt.Errorf("status_code should be set in connect_code_to_http_response.transforms[%d].to", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCodeToUniDisconnectTransforms(transforms configtypes.UniConnectCodeToDisconnectTransforms) error {
|
||||
for i, t := range transforms {
|
||||
if t.Code == 0 {
|
||||
return fmt.Errorf("no code specified in transforms[%d]", i)
|
||||
}
|
||||
if t.To.Code == 0 {
|
||||
return fmt.Errorf("no disconnect code specified in transforms[%d].to", i)
|
||||
}
|
||||
if !tools.IsASCII(t.To.Reason) {
|
||||
return fmt.Errorf("disconnect reason must be ASCII in transforms[%d].to", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
707
internal/config/validate_test.go
Normal file
707
internal/config/validate_test.go
Normal file
@@ -0,0 +1,707 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidatePublicationDataFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty string is valid",
|
||||
format: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json is valid",
|
||||
format: configtypes.PublicationDataFormatJSON,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json_object is valid",
|
||||
format: configtypes.PublicationDataFormatJSONObject,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "binary is valid",
|
||||
format: configtypes.PublicationDataFormatBinary,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "unknown format xml is invalid",
|
||||
format: "xml",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unknown format text is invalid",
|
||||
format: "text",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "random string is invalid",
|
||||
format: "foobar",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Channel.WithoutNamespace.PublicationDataFormat = tt.format
|
||||
err := cfg.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown publication_data_format")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicationDataFormatInNamespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "json in namespace",
|
||||
format: "json",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json_object in namespace",
|
||||
format: "json_object",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "binary in namespace",
|
||||
format: "binary",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid format in namespace",
|
||||
format: "invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: "test",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
PublicationDataFormat: tt.format,
|
||||
},
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown publication_data_format")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGlobalPublicationDataFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty global format is valid",
|
||||
format: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json global format is valid",
|
||||
format: "json",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "binary global format is valid",
|
||||
format: "binary",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid global format",
|
||||
format: "xml",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Channel.PublicationDataFormat = tt.format
|
||||
err := cfg.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown channel.publication_data_format")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mapDefaultConfig returns a Config suitable as a base for map namespace validation tests.
|
||||
func mapDefaultConfig() Config {
|
||||
cfg := DefaultConfig()
|
||||
return cfg
|
||||
}
|
||||
|
||||
func mapNamespace(name, mode string) configtypes.ChannelNamespace {
|
||||
return configtypes.ChannelNamespace{
|
||||
Name: name,
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "map",
|
||||
Map: configtypes.MapConfig{Mode: mode},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMapNamespace_Ephemeral(t *testing.T) {
|
||||
t.Run("valid_minimal", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ee", "ephemeral")
|
||||
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("missing_key_ttl", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ee", "ephemeral")
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "map.key_ttl is required")
|
||||
})
|
||||
|
||||
t.Run("stream_size_rejected", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ee", "ephemeral")
|
||||
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
ns.Map.StreamSize = 100
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "map.stream_size must be 0")
|
||||
})
|
||||
|
||||
t.Run("stream_ttl_rejected", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ee", "ephemeral")
|
||||
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
ns.Map.StreamTTL = configtypes.Duration(time.Minute)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "map.stream_ttl must be 0")
|
||||
})
|
||||
|
||||
t.Run("meta_ttl_rejected", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ee", "ephemeral")
|
||||
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
ns.Map.MetaTTL = configtypes.Duration(10 * time.Minute)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "map.meta_ttl must be 0")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateAutoCacheRecover(t *testing.T) {
|
||||
t.Run("valid_with_cache_recovery", func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
opts := &cfg.Channel.WithoutNamespace
|
||||
opts.HistorySize = 1
|
||||
opts.HistoryTTL = configtypes.Duration(time.Hour)
|
||||
opts.ForceRecovery = true
|
||||
opts.ForceRecoveryMode = "cache"
|
||||
opts.AutoCacheRecover = true
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("requires_force_recovery", func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
opts := &cfg.Channel.WithoutNamespace
|
||||
opts.HistorySize = 1
|
||||
opts.HistoryTTL = configtypes.Duration(time.Hour)
|
||||
opts.ForceRecoveryMode = "cache"
|
||||
opts.AutoCacheRecover = true
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "auto_cache_recover requires force_recovery")
|
||||
})
|
||||
|
||||
t.Run("requires_cache_mode", func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
opts := &cfg.Channel.WithoutNamespace
|
||||
opts.HistorySize = 1
|
||||
opts.HistoryTTL = configtypes.Duration(time.Hour)
|
||||
opts.ForceRecovery = true
|
||||
opts.AutoCacheRecover = true
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "force_recovery_mode set to cache")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateMapNamespace_Recoverable(t *testing.T) {
|
||||
t.Run("valid_minimal_defaults_zero", func(t *testing.T) {
|
||||
// Recoverable with all stream options at zero is valid
|
||||
// because centrifuge auto-derives defaults at runtime.
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ce", "recoverable")
|
||||
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("valid_explicit_stream_options", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ce", "recoverable")
|
||||
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
|
||||
ns.Map.StreamSize = 200
|
||||
ns.Map.StreamTTL = configtypes.Duration(2 * time.Minute)
|
||||
ns.Map.MetaTTL = configtypes.Duration(20 * time.Minute)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("missing_key_ttl", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ce", "recoverable")
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "map.key_ttl is required")
|
||||
})
|
||||
|
||||
t.Run("meta_ttl_less_than_stream_ttl", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ce", "recoverable")
|
||||
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
|
||||
ns.Map.StreamTTL = configtypes.Duration(10 * time.Minute)
|
||||
ns.Map.MetaTTL = configtypes.Duration(5 * time.Minute) // less than stream_ttl
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "metadata must outlive stream")
|
||||
})
|
||||
|
||||
t.Run("meta_ttl_equals_stream_ttl_ok", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ce", "recoverable")
|
||||
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
|
||||
ns.Map.StreamTTL = configtypes.Duration(5 * time.Minute)
|
||||
ns.Map.MetaTTL = configtypes.Duration(5 * time.Minute) // equals stream_ttl
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("only_stream_ttl_set_meta_ttl_zero_ok", func(t *testing.T) {
|
||||
// When only stream_ttl is set, meta_ttl=0 means auto-derived by centrifuge.
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ce", "recoverable")
|
||||
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
|
||||
ns.Map.StreamTTL = configtypes.Duration(5 * time.Minute)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("only_meta_ttl_set_stream_ttl_zero_ok", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ce", "recoverable")
|
||||
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
|
||||
ns.Map.MetaTTL = configtypes.Duration(10 * time.Minute)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("meta_ttl_less_than_auto_derived_stream_ttl", func(t *testing.T) {
|
||||
// StreamTTL=0 will be auto-derived to 1min by centrifuge.
|
||||
// MetaTTL=30s is less than that — must be caught at config time.
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ce", "recoverable")
|
||||
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
|
||||
ns.Map.MetaTTL = configtypes.Duration(30 * time.Second) // < auto-derived 1min
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "metadata must outlive stream")
|
||||
})
|
||||
|
||||
t.Run("meta_ttl_equals_auto_derived_stream_ttl_ok", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ce", "recoverable")
|
||||
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
|
||||
ns.Map.MetaTTL = configtypes.Duration(1 * time.Minute) // == auto-derived 1min
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateMapNamespace_Persistent(t *testing.T) {
|
||||
t.Run("valid_minimal", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("cp", "persistent")
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("valid_with_stream_options", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("cp", "persistent")
|
||||
ns.Map.StreamSize = 500
|
||||
ns.Map.StreamTTL = configtypes.Duration(5 * time.Minute)
|
||||
ns.Map.MetaTTL = configtypes.Duration(50 * time.Minute)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("key_ttl_rejected", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("cp", "persistent")
|
||||
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "map.key_ttl must not be set")
|
||||
})
|
||||
|
||||
t.Run("meta_ttl_less_than_stream_ttl", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("cp", "persistent")
|
||||
ns.Map.StreamTTL = configtypes.Duration(10 * time.Minute)
|
||||
ns.Map.MetaTTL = configtypes.Duration(1 * time.Minute)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "metadata must outlive stream")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateMapNamespace_RequiredFields(t *testing.T) {
|
||||
t.Run("missing_mode", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{{
|
||||
Name: "ns",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "map",
|
||||
Map: configtypes.MapConfig{},
|
||||
},
|
||||
}}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "map.mode is required")
|
||||
})
|
||||
|
||||
t.Run("invalid_mode", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ns", "unknown_mode")
|
||||
ns.Map.KeyTTL = configtypes.Duration(30 * time.Second)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown map.mode")
|
||||
})
|
||||
|
||||
t.Run("invalid_subscription_type", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{{
|
||||
Name: "ns",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "invalid_type",
|
||||
},
|
||||
}}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown subscription_type")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateMapNamespace_SubscriptionType(t *testing.T) {
|
||||
t.Run("map_clients", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ns", "ephemeral")
|
||||
ns.SubscriptionType = "map_clients"
|
||||
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("map_users", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ns", "ephemeral")
|
||||
ns.SubscriptionType = "map_users"
|
||||
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("stream_only_no_map_config_needed", func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{{
|
||||
Name: "ns",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "stream",
|
||||
},
|
||||
}}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("map_clients_persistent_rejected", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ns", "persistent")
|
||||
ns.SubscriptionType = "map_clients"
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), `map.mode "persistent" is not allowed with subscription_type "map_clients"`)
|
||||
})
|
||||
|
||||
t.Run("map_users_persistent_rejected", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ns", "persistent")
|
||||
ns.SubscriptionType = "map_users"
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), `map.mode "persistent" is not allowed with subscription_type "map_users"`)
|
||||
})
|
||||
|
||||
t.Run("client_key_with_publish_proxy_rejected", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ns", "ephemeral")
|
||||
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
ns.Map.ClientKey = "client_id"
|
||||
ns.Map.PublishProxyEnabled = true
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "map.client_key is not compatible with map.publish_proxy_enabled")
|
||||
})
|
||||
|
||||
t.Run("client_key_with_remove_proxy_rejected", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ns", "ephemeral")
|
||||
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
ns.Map.ClientKey = "user_id"
|
||||
ns.Map.RemoveProxyEnabled = true
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "map.client_key is not compatible with")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateMapNamespace_PresenceAndRemoveOptions(t *testing.T) {
|
||||
t.Run("remove_on_unsubscribe_valid", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("ns", "ephemeral")
|
||||
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
ns.Map.RemoveClientOnUnsubscribe = true
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("remove_on_unsubscribe_requires_map_type", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{{
|
||||
Name: "ns",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "stream",
|
||||
Map: configtypes.MapConfig{RemoveClientOnUnsubscribe: true},
|
||||
},
|
||||
}}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "map.remove_client_on_unsubscribe requires subscription_type to be a map type")
|
||||
})
|
||||
|
||||
t.Run("map_clients_presence_channel_prefix_valid", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("games", "ephemeral")
|
||||
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
ns.MapClientsPresenceChannelPrefix = "clients:"
|
||||
clients := mapNamespace("clients", "ephemeral")
|
||||
clients.SubscriptionType = "map_clients"
|
||||
clients.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns, clients}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("map_clients_presence_channel_prefix_not_found", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("games", "ephemeral")
|
||||
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
ns.MapClientsPresenceChannelPrefix = "nonexistent:"
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "map_clients_presence_channel_prefix")
|
||||
require.Contains(t, err.Error(), "does not exist")
|
||||
})
|
||||
|
||||
t.Run("map_users_presence_channel_prefix_not_found", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
ns := mapNamespace("games", "ephemeral")
|
||||
ns.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
ns.MapUsersPresenceChannelPrefix = "nonexistent:"
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{ns}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "map_users_presence_channel_prefix")
|
||||
require.Contains(t, err.Error(), "does not exist")
|
||||
})
|
||||
|
||||
t.Run("presence_channel_prefix_works_with_stream_type", func(t *testing.T) {
|
||||
cfg := mapDefaultConfig()
|
||||
clients := mapNamespace("clients", "ephemeral")
|
||||
clients.SubscriptionType = "map_clients"
|
||||
clients.Map.KeyTTL = configtypes.Duration(60 * time.Second)
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{{
|
||||
Name: "ns",
|
||||
ChannelOptions: configtypes.ChannelOptions{
|
||||
SubscriptionType: "stream",
|
||||
MapClientsPresenceChannelPrefix: "clients:",
|
||||
},
|
||||
}, clients}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateMapNamespace_MapBrokerType(t *testing.T) {
|
||||
t.Run("unknown_type", func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.MapBroker.Type = "unknown"
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown map broker type")
|
||||
})
|
||||
|
||||
for _, brokerType := range []string{"memory", "redis", "postgres"} {
|
||||
t.Run("valid_"+brokerType, func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.MapBroker.Type = brokerType
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConnectCodeToHTTPResponseTransforms(t *testing.T) {
|
||||
validTransform := configtypes.ConnectCodeToHTTPResponseTransform{
|
||||
Code: 3500,
|
||||
To: configtypes.TransformedConnectErrorHttpResponse{
|
||||
StatusCode: 403,
|
||||
Body: "forbidden",
|
||||
},
|
||||
}
|
||||
noCode := validTransform
|
||||
noCode.Code = 0
|
||||
noStatusCode := validTransform
|
||||
noStatusCode.To.StatusCode = 0
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
transforms configtypes.ConnectCodeToHTTPResponseTransforms
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid transform",
|
||||
transforms: configtypes.ConnectCodeToHTTPResponseTransforms{validTransform},
|
||||
},
|
||||
{
|
||||
name: "missing code",
|
||||
transforms: configtypes.ConnectCodeToHTTPResponseTransforms{noCode},
|
||||
wantErr: "code should be set in connect_code_to_http_response.transforms[0]",
|
||||
},
|
||||
{
|
||||
name: "missing status code",
|
||||
transforms: configtypes.ConnectCodeToHTTPResponseTransforms{validTransform, noStatusCode},
|
||||
wantErr: "status_code should be set in connect_code_to_http_response.transforms[1].to",
|
||||
},
|
||||
}
|
||||
|
||||
// The same transforms are validated for both unidirectional HTTP transports,
|
||||
// each reporting under its own config path.
|
||||
transports := []struct {
|
||||
name string
|
||||
prefix string
|
||||
set func(cfg *Config, transforms configtypes.ConnectCodeToHTTPResponseTransforms)
|
||||
}{
|
||||
{
|
||||
name: "uni_sse",
|
||||
prefix: "in uni_sse.connect_code_to_http_response.transforms: ",
|
||||
set: func(cfg *Config, transforms configtypes.ConnectCodeToHTTPResponseTransforms) {
|
||||
cfg.UniSSE.ConnectCodeToHTTPResponse.Transforms = transforms
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uni_http_stream",
|
||||
prefix: "in uni_http_stream.connect_code_to_http_response.transforms: ",
|
||||
set: func(cfg *Config, transforms configtypes.ConnectCodeToHTTPResponseTransforms) {
|
||||
cfg.UniHTTPStream.ConnectCodeToHTTPResponse.Transforms = transforms
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, transport := range transports {
|
||||
for _, tt := range tests {
|
||||
t.Run(transport.name+"/"+tt.name, func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
transport.set(&cfg, tt.transforms)
|
||||
err := cfg.Validate()
|
||||
if tt.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.EqualError(t, err, transport.prefix+tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDuplicateProxyName(t *testing.T) {
|
||||
proxy := configtypes.Proxy{
|
||||
Endpoint: "http://localhost:3000/proxy",
|
||||
Timeout: configtypes.Duration(time.Second),
|
||||
}
|
||||
|
||||
t.Run("unique names are valid", func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Proxies = configtypes.NamedProxies{
|
||||
{Name: "first", Proxy: proxy},
|
||||
{Name: "second", Proxy: proxy},
|
||||
}
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("duplicate name is invalid", func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Proxies = configtypes.NamedProxies{
|
||||
{Name: "first", Proxy: proxy},
|
||||
{Name: "first", Proxy: proxy},
|
||||
}
|
||||
require.EqualError(t, cfg.Validate(), "duplicate proxy name in proxies: first")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user