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

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

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

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