snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user