Files
upstream-centrifugo/internal/config/validate.go
upstream-mirror b352a9a30d
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s
snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:17:21 +05:00

607 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}