snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
80
internal/configtypes/duration.go
Normal file
80
internal/configtypes/duration.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package configtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
type Duration time.Duration
|
||||
|
||||
func (d Duration) String() string {
|
||||
return d.ToDuration().String()
|
||||
}
|
||||
|
||||
// ToDuration converts the Duration type to time.Duration.
|
||||
func (d Duration) ToDuration() time.Duration {
|
||||
return time.Duration(d)
|
||||
}
|
||||
|
||||
// UnmarshalJSON interprets numeric values as nanoseconds (default behavior),
|
||||
// and strings using time.ParseDuration.
|
||||
func (d *Duration) UnmarshalJSON(b []byte) error {
|
||||
// Check if it's a string (starts with a quote).
|
||||
if len(b) > 0 && b[0] == '"' {
|
||||
var str string
|
||||
if err := json.Unmarshal(b, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := time.ParseDuration(str)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = Duration(parsed)
|
||||
return nil
|
||||
}
|
||||
// Otherwise assume it's a numeric value in nanoseconds.
|
||||
var ns int64
|
||||
if err := json.Unmarshal(b, &ns); err != nil {
|
||||
return err
|
||||
}
|
||||
*d = Duration(ns)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON for JSON encoding.
|
||||
func (d Duration) MarshalJSON() ([]byte, error) {
|
||||
durationStr := time.Duration(d).String()
|
||||
return json.Marshal(durationStr)
|
||||
}
|
||||
|
||||
// MarshalText for TOML encoding.
|
||||
func (d Duration) MarshalText() ([]byte, error) {
|
||||
durationStr := time.Duration(d).String()
|
||||
return []byte(durationStr), nil
|
||||
}
|
||||
|
||||
// MarshalYAML for YAML encoding.
|
||||
func (d Duration) MarshalYAML() (interface{}, error) {
|
||||
durationStr := time.Duration(d).String()
|
||||
return durationStr, nil
|
||||
}
|
||||
|
||||
func StringToDurationHookFunc() mapstructure.DecodeHookFunc {
|
||||
return func(
|
||||
f reflect.Type,
|
||||
t reflect.Type,
|
||||
data interface{}) (interface{}, error) {
|
||||
if f.Kind() != reflect.String {
|
||||
return data, nil
|
||||
}
|
||||
if t != reflect.TypeOf(Duration(5)) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Convert it by parsing
|
||||
return time.ParseDuration(data.(string))
|
||||
}
|
||||
}
|
||||
271
internal/configtypes/engine.go
Normal file
271
internal/configtypes/engine.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package configtypes
|
||||
|
||||
type Engine struct {
|
||||
// Type of broker to use. Can be `memory` or `redis` at this point.
|
||||
Type string `mapstructure:"type" default:"memory" json:"type" envconfig:"type" yaml:"type" toml:"type" expose:"full" doc:"Engine type used for broker and presence manager. Can be <<memory>> or <<redis>>."`
|
||||
// Redis is a configuration for `redis` broker.
|
||||
Redis RedisEngine `mapstructure:"redis" json:"redis" envconfig:"redis" toml:"redis" yaml:"redis" doc:"Redis engine configuration, used when type is <<redis>>."`
|
||||
}
|
||||
|
||||
type RedisBrokerCommon struct {
|
||||
// UseLists enables usage of Redis Lists for history storage. Lists do not support pagination,
|
||||
// idempotent publish and reverse order history retrieval.
|
||||
UseLists bool `mapstructure:"history_use_lists" json:"history_use_lists" envconfig:"history_use_lists" yaml:"history_use_lists" toml:"history_use_lists" doc:"Use Redis Lists for history storage. Lists do not support history pagination, idempotent publishing or reverse-order history retrieval."`
|
||||
}
|
||||
|
||||
type RedisBroker struct {
|
||||
RedisPrefixed `mapstructure:",squash" yaml:",inline"`
|
||||
RedisBrokerCommon `mapstructure:",squash" yaml:",inline"`
|
||||
}
|
||||
|
||||
type EngineRedisBroker struct {
|
||||
RedisBrokerCommon `mapstructure:",squash" yaml:",inline"`
|
||||
}
|
||||
|
||||
type RedisPresenceManagerCommon struct {
|
||||
// PresenceTTL is a period of time while presence information is considered valid.
|
||||
PresenceTTL Duration `mapstructure:"presence_ttl" json:"presence_ttl" envconfig:"presence_ttl" default:"60s" yaml:"presence_ttl" toml:"presence_ttl" doc:"How long presence information stays valid before it expires. Default <<60s>>."`
|
||||
// PresenceHashFieldTTL enables using hash per-field expiration for presence.
|
||||
PresenceHashFieldTTL bool `mapstructure:"presence_hash_field_ttl" json:"presence_hash_field_ttl" envconfig:"presence_hash_field_ttl" yaml:"presence_hash_field_ttl" toml:"presence_hash_field_ttl" doc:"Use Redis hash per-field expiration for presence (requires Redis 7.4+)."`
|
||||
// PresenceUserMapping enables optimization for presence stats keeping a separate hash of subscribed users in Redis.
|
||||
PresenceUserMapping bool `mapstructure:"presence_user_mapping" json:"presence_user_mapping" envconfig:"presence_user_mapping" yaml:"presence_user_mapping" toml:"presence_user_mapping" doc:"Optimize presence stats by keeping a separate Redis hash of subscribed users."`
|
||||
}
|
||||
|
||||
type EngineRedisPresenceManager struct {
|
||||
RedisPresenceManagerCommon `mapstructure:",squash" yaml:",inline"`
|
||||
}
|
||||
|
||||
type RedisPresenceManager struct {
|
||||
RedisPrefixed `mapstructure:",squash" yaml:",inline"`
|
||||
RedisPresenceManagerCommon `mapstructure:",squash" yaml:",inline"`
|
||||
}
|
||||
|
||||
// RedisNatsBroker configuration.
|
||||
type RedisNatsBroker struct {
|
||||
Redis RedisBroker `mapstructure:"redis" json:"redis" envconfig:"redis" toml:"redis" yaml:"redis" doc:"Redis part of the Redis + NATS broker configuration."`
|
||||
Nats NatsBroker `mapstructure:"nats" json:"nats" envconfig:"nats" toml:"nats" yaml:"nats" doc:"NATS part of the Redis + NATS broker configuration."`
|
||||
}
|
||||
|
||||
// RedisEngine configuration.
|
||||
type RedisEngine struct {
|
||||
RedisPrefixed `mapstructure:",squash" yaml:",inline"`
|
||||
EngineRedisBroker `mapstructure:",squash" yaml:",inline"`
|
||||
EngineRedisPresenceManager `mapstructure:",squash" yaml:",inline"`
|
||||
}
|
||||
|
||||
type Broker struct {
|
||||
Enabled bool `mapstructure:"enabled" json:"enabled" envconfig:"enabled" yaml:"enabled" toml:"enabled" doc:"Enables a standalone broker, configured separately from the engine."`
|
||||
// Type of broker to use. Can be "memory", "redis", "nats", "postgres" at this point.
|
||||
Type string `mapstructure:"type" default:"memory" json:"type" envconfig:"type" yaml:"type" toml:"type" expose:"full" doc:"Broker type. Can be <<memory>>, <<redis>>, <<nats>> or <<postgres>>."`
|
||||
// Redis is a configuration for "redis" broker.
|
||||
Redis RedisBroker `mapstructure:"redis" json:"redis" envconfig:"redis" toml:"redis" yaml:"redis" doc:"Redis broker configuration, used when type is <<redis>>."`
|
||||
// Nats is a configuration for NATS broker. It does not support history/recovery/cache.
|
||||
Nats NatsBroker `mapstructure:"nats" json:"nats" envconfig:"nats" toml:"nats" yaml:"nats" doc:"NATS broker configuration, used when type is <<nats>>. Note: NATS broker does not support history, recovery or cache."`
|
||||
// Postgres is a configuration for "postgres" stream broker (PG-backed
|
||||
// implementation of centrifuge.Broker for stream subscriptions).
|
||||
Postgres PostgresStreamBroker `mapstructure:"postgres" json:"postgres" envconfig:"postgres" toml:"postgres" yaml:"postgres" doc:"PostgreSQL stream broker configuration, used when type is <<postgres>>."`
|
||||
// RedisNats is a configuration for Redis + NATS broker. It's highly experimental, undocumented and
|
||||
// can only be used when enable_unreleased_features option is set to true.
|
||||
RedisNats *RedisNatsBroker `mapstructure:"redisnats" json:"redisnats,omitempty" envconfig:"redisnats" toml:"redisnats,omitempty" yaml:"redisnats,omitempty" expose:"-" doc:"Highly experimental Redis + NATS broker configuration. Only usable when enable_unreleased_features is true; do not use in production."`
|
||||
}
|
||||
|
||||
type PresenceManager struct {
|
||||
Enabled bool `mapstructure:"enabled" json:"enabled" envconfig:"enabled" yaml:"enabled" toml:"enabled" doc:"Enables a standalone presence manager, configured separately from the engine."`
|
||||
// Type of presence manager to use. Can be "memory" or "redis" at this point.
|
||||
Type string `mapstructure:"type" default:"memory" json:"type" envconfig:"type" yaml:"type" toml:"type" expose:"full" doc:"Presence manager type. Can be <<memory>> or <<redis>>."`
|
||||
// Redis is a configuration for "redis" broker.
|
||||
Redis RedisPresenceManager `mapstructure:"redis" json:"redis" envconfig:"redis" toml:"redis" yaml:"redis" doc:"Redis presence manager configuration, used when type is <<redis>>."`
|
||||
}
|
||||
|
||||
// MapBroker configures the map broker used for synchronized keyed state channels.
|
||||
type MapBroker struct {
|
||||
// Type of map broker to use. Can be "memory", "redis", or "postgres".
|
||||
Type string `mapstructure:"type" default:"memory" json:"type" envconfig:"type" yaml:"type" toml:"type" expose:"full" doc:"Map broker type for synchronized keyed state channels. Can be <<memory>>, <<redis>> or <<postgres>>."`
|
||||
// Redis is a configuration for "redis" map broker.
|
||||
Redis RedisMapBroker `mapstructure:"redis" json:"redis" envconfig:"redis" toml:"redis" yaml:"redis" doc:"Redis map broker configuration, used when type is <<redis>>."`
|
||||
// Postgres is a configuration for "postgres" map broker.
|
||||
Postgres PostgresMapBroker `mapstructure:"postgres" json:"postgres" envconfig:"postgres" toml:"postgres" yaml:"postgres" doc:"PostgreSQL map broker configuration, used when type is <<postgres>>."`
|
||||
}
|
||||
|
||||
// RedisMapBroker is a configuration for Redis-based map broker.
|
||||
type RedisMapBroker struct {
|
||||
RedisPrefixed `mapstructure:",squash" yaml:",inline"`
|
||||
RedisMapBrokerCommon `mapstructure:",squash" yaml:",inline"`
|
||||
}
|
||||
|
||||
// RedisMapBrokerCommon contains common Redis map broker settings shared between
|
||||
// standalone and engine-level configurations.
|
||||
type RedisMapBrokerCommon struct {
|
||||
// CleanupInterval defines how often to run the cleanup worker that generates
|
||||
// remove events for expired keyed state entries. Default: "1s".
|
||||
// Set to "-1" to disable cleanup.
|
||||
CleanupInterval Duration `mapstructure:"cleanup_interval" json:"cleanup_interval" envconfig:"cleanup_interval" default:"1s" yaml:"cleanup_interval" toml:"cleanup_interval" doc:"How often the cleanup worker emits remove events for expired keyed state entries. Default <<1s>>. Set to <<-1>> to disable cleanup."`
|
||||
// CleanupBatchSize defines max entries to process per channel per cleanup cycle.
|
||||
// Default: 100.
|
||||
CleanupBatchSize int `mapstructure:"cleanup_batch_size" json:"cleanup_batch_size" envconfig:"cleanup_batch_size" default:"100" yaml:"cleanup_batch_size" toml:"cleanup_batch_size" doc:"Maximum number of entries to process per channel per cleanup cycle. Default <<100>>."`
|
||||
// IdempotentResultTTL is a time-to-live for idempotent publish results.
|
||||
// Default: "5m".
|
||||
IdempotentResultTTL Duration `mapstructure:"idempotent_result_ttl" json:"idempotent_result_ttl" envconfig:"idempotent_result_ttl" default:"5m" yaml:"idempotent_result_ttl" toml:"idempotent_result_ttl" doc:"How long idempotent publish results are cached. Default <<5m>>."`
|
||||
// SkipPubSub disables PUB/SUB for the map broker. When true, the broker only works
|
||||
// with data structures without publishing to channels. Useful when PUB/SUB is handled
|
||||
// by another component.
|
||||
SkipPubSub bool `mapstructure:"skip_pub_sub" json:"skip_pub_sub" envconfig:"skip_pub_sub" yaml:"skip_pub_sub" toml:"skip_pub_sub" doc:"Disable PUB/SUB for the map broker, so it only updates data structures without publishing to channels. Useful when PUB/SUB is handled by another component."`
|
||||
}
|
||||
|
||||
// PostgresMapBroker is a configuration for PostgreSQL-based map broker.
|
||||
type PostgresMapBroker struct {
|
||||
// DSN is the primary PostgreSQL connection string.
|
||||
// Example: "postgres://user:pass@localhost:5432/dbname?sslmode=disable".
|
||||
DSN string `mapstructure:"dsn" json:"dsn" envconfig:"dsn" yaml:"dsn" toml:"dsn" expose:"url" doc:"Primary PostgreSQL connection string, e.g. <<postgres://user:pass@localhost:5432/dbname?sslmode=disable>>."`
|
||||
// TLS is an optional TLS configuration for all PostgreSQL connections
|
||||
// (primary, replicas, and notify). Use instead of embedding TLS parameters
|
||||
// in the DSN when certificate files are managed outside the connection string.
|
||||
TLS TLSConfig `mapstructure:"tls" json:"tls" envconfig:"tls" yaml:"tls" toml:"tls" doc:"Optional TLS configuration applied to all PostgreSQL connections (primary, replicas and notify). Use this instead of embedding TLS parameters in the DSN."`
|
||||
// PoolSize sets the maximum number of connections in the pool. Default: 16.
|
||||
PoolSize int `mapstructure:"pool_size" json:"pool_size" envconfig:"pool_size" default:"16" yaml:"pool_size" toml:"pool_size" doc:"Maximum number of connections in the pool. Default <<16>>."`
|
||||
// NumShards is the total number of shards for parallel delivery workers.
|
||||
// Channels are distributed across shards using consistent hashing. Default: 8.
|
||||
NumShards int `mapstructure:"num_shards" json:"num_shards" envconfig:"num_shards" default:"8" yaml:"num_shards" toml:"num_shards" doc:"Number of shards for parallel delivery workers. Channels are distributed across shards using consistent hashing. Default <<8>>."`
|
||||
// TTLCheckInterval is how often to check for expired keys. Default: "1s".
|
||||
TTLCheckInterval Duration `mapstructure:"ttl_check_interval" json:"ttl_check_interval" envconfig:"ttl_check_interval" default:"1s" yaml:"ttl_check_interval" toml:"ttl_check_interval" doc:"How often to check for expired keys. Default <<1s>>."`
|
||||
// CleanupInterval is how often to clean up expired stream/meta/idempotency entries.
|
||||
// Default: "1m".
|
||||
CleanupInterval Duration `mapstructure:"cleanup_interval" json:"cleanup_interval" envconfig:"cleanup_interval" default:"1m" yaml:"cleanup_interval" toml:"cleanup_interval" doc:"How often to clean up expired stream, meta and idempotency entries. Default <<1m>>."`
|
||||
// IdempotentResultTTL is the default TTL for idempotency keys. Default: "5m".
|
||||
IdempotentResultTTL Duration `mapstructure:"idempotent_result_ttl" json:"idempotent_result_ttl" envconfig:"idempotent_result_ttl" default:"5m" yaml:"idempotent_result_ttl" toml:"idempotent_result_ttl" doc:"Default TTL for idempotency keys. Default <<5m>>."`
|
||||
// BinaryData uses BYTEA columns instead of JSONB for data fields.
|
||||
// Set to true if data payloads are not valid JSON (e.g. binary/protobuf).
|
||||
BinaryData bool `mapstructure:"binary_data" json:"binary_data" envconfig:"binary_data" yaml:"binary_data" toml:"binary_data" doc:"Store data in BYTEA columns instead of JSONB. Enable when payloads are not valid JSON (e.g. binary or protobuf)."`
|
||||
// StreamRetention controls how long stream entries are kept. Default: "24h".
|
||||
StreamRetention Duration `mapstructure:"stream_retention" json:"stream_retention" envconfig:"stream_retention" default:"24h" yaml:"stream_retention" toml:"stream_retention" doc:"How long stream entries are kept. Default <<24h>>."`
|
||||
// UseNotify enables LISTEN/NOTIFY for low-latency outbox wakeup.
|
||||
// When false (default), outbox worker uses polling only.
|
||||
UseNotify bool `mapstructure:"use_notify" json:"use_notify" envconfig:"use_notify" yaml:"use_notify" toml:"use_notify" doc:"Use PostgreSQL LISTEN/NOTIFY for low-latency outbox wakeup. When disabled (default), the outbox worker relies on polling only."`
|
||||
// NotifyDSN is an optional separate DSN for the LISTEN connection.
|
||||
// Required when DSN points at PGBouncer (transaction pooling mode is
|
||||
// incompatible with LISTEN/NOTIFY). Must be a direct PostgreSQL URL.
|
||||
NotifyDSN string `mapstructure:"notify_dsn" json:"notify_dsn" envconfig:"notify_dsn" yaml:"notify_dsn" toml:"notify_dsn" expose:"url" doc:"Optional separate DSN for the LISTEN connection. Required when the main DSN points at PGBouncer (transaction pooling is incompatible with LISTEN/NOTIFY); must be a direct PostgreSQL URL."`
|
||||
// TablePrefix is the namespace prefix for all tables created by this broker.
|
||||
// Default: "cf". Multi-tenant deployments sharing one PostgreSQL instance
|
||||
// use distinct prefixes per Centrifugo cluster (e.g. "prod_us_cf").
|
||||
TablePrefix string `mapstructure:"table_prefix" json:"table_prefix" envconfig:"table_prefix" default:"cf" yaml:"table_prefix" toml:"table_prefix" expose:"full" doc:"Namespace prefix for all tables created by this broker. Default <<cf>>. Use distinct prefixes per cluster when several Centrifugo deployments share one PostgreSQL instance."`
|
||||
// SkipSchemaInit disables automatic schema initialization on startup.
|
||||
// When true, the schema must be managed externally (e.g. via migrations).
|
||||
SkipSchemaInit bool `mapstructure:"skip_schema_init" json:"skip_schema_init" envconfig:"skip_schema_init" yaml:"skip_schema_init" toml:"skip_schema_init" doc:"Disable automatic schema initialization on startup. When enabled, you must manage the schema externally (e.g. via migrations)."`
|
||||
// Outbox configures the outbox-based delivery mode.
|
||||
Outbox PostgresMapBrokerOutbox `mapstructure:"outbox" json:"outbox" envconfig:"outbox" yaml:"outbox" toml:"outbox" doc:"Outbox-based delivery configuration."`
|
||||
// PartitionLookaheadDays controls how many future daily partitions to
|
||||
// pre-create. Required > 0 so writes don't fail at the day rollover.
|
||||
// Default: 2 (gives a 48-hour safety window if the lookahead worker stalls).
|
||||
PartitionLookaheadDays int `mapstructure:"partition_lookahead_days" json:"partition_lookahead_days" envconfig:"partition_lookahead_days" default:"2" yaml:"partition_lookahead_days" toml:"partition_lookahead_days" doc:"How many future daily partitions to pre-create. Must be greater than 0 so writes don't fail at the day rollover. Default <<2>> (a 48-hour safety window)."`
|
||||
// PartitionRetentionDays controls how old a partition can be before it
|
||||
// gets dropped whole by the partition retention worker. Default: 7.
|
||||
// Set to a large value for longer retention; the special value 0 is
|
||||
// internally promoted to 7 (treat "not set" as default).
|
||||
PartitionRetentionDays int `mapstructure:"partition_retention_days" json:"partition_retention_days" envconfig:"partition_retention_days" default:"7" yaml:"partition_retention_days" toml:"partition_retention_days" doc:"How many days a partition is kept before the retention worker drops it whole. Default <<7>>. Use a larger value for longer retention."`
|
||||
}
|
||||
|
||||
// PostgresMapBrokerOutbox configures the outbox-based delivery for PostgreSQL map broker.
|
||||
type PostgresMapBrokerOutbox struct {
|
||||
// PollInterval is how often to poll for new stream entries when idle. Default: "100ms".
|
||||
PollInterval Duration `mapstructure:"poll_interval" json:"poll_interval" envconfig:"poll_interval" default:"100ms" yaml:"poll_interval" toml:"poll_interval" doc:"How often to poll for new stream entries when idle. Default <<100ms>>."`
|
||||
// BatchSize is the maximum number of rows to process per batch. Default: 1000.
|
||||
BatchSize int `mapstructure:"batch_size" json:"batch_size" envconfig:"batch_size" default:"1000" yaml:"batch_size" toml:"batch_size" doc:"Maximum number of rows to process per batch. Default <<1000>>."`
|
||||
}
|
||||
|
||||
// PostgresStreamBroker is a configuration for PostgreSQL-based stream broker.
|
||||
// It implements centrifuge.Broker for stream subscriptions, providing
|
||||
// transactional publishing alongside business writes in the same SQL transaction.
|
||||
type PostgresStreamBroker struct {
|
||||
// DSN is the primary PostgreSQL connection string.
|
||||
DSN string `mapstructure:"dsn" json:"dsn" envconfig:"dsn" yaml:"dsn" toml:"dsn" expose:"url" doc:"Primary PostgreSQL connection string, e.g. <<postgres://user:pass@localhost:5432/dbname?sslmode=disable>>."`
|
||||
// TLS is an optional TLS configuration for all PostgreSQL connections
|
||||
// (primary, replicas, and notify). Use instead of embedding TLS parameters
|
||||
// in the DSN when certificate files are managed outside the connection string.
|
||||
TLS TLSConfig `mapstructure:"tls" json:"tls" envconfig:"tls" yaml:"tls" toml:"tls" doc:"Optional TLS configuration applied to all PostgreSQL connections (primary, replicas and notify). Use this instead of embedding TLS parameters in the DSN."`
|
||||
// PoolSize sets the maximum number of connections in the pool. Default: 16.
|
||||
PoolSize int `mapstructure:"pool_size" json:"pool_size" envconfig:"pool_size" default:"16" yaml:"pool_size" toml:"pool_size" doc:"Maximum number of connections in the pool. Default <<16>>."`
|
||||
// NumShards is the total number of shards for parallel delivery workers.
|
||||
// Channels are distributed across shards using consistent hashing. Default: 8.
|
||||
NumShards int `mapstructure:"num_shards" json:"num_shards" envconfig:"num_shards" default:"8" yaml:"num_shards" toml:"num_shards" doc:"Number of shards for parallel delivery workers. Channels are distributed across shards using consistent hashing. Default <<8>>."`
|
||||
// CleanupInterval is how often the cleanup and partition workers tick. Default: "1m".
|
||||
CleanupInterval Duration `mapstructure:"cleanup_interval" json:"cleanup_interval" envconfig:"cleanup_interval" default:"1m" yaml:"cleanup_interval" toml:"cleanup_interval" doc:"How often the cleanup and partition workers run. Default <<1m>>."`
|
||||
// IdempotentResultTTL is the default TTL for idempotency cache entries. Default: "5m".
|
||||
IdempotentResultTTL Duration `mapstructure:"idempotent_result_ttl" json:"idempotent_result_ttl" envconfig:"idempotent_result_ttl" default:"5m" yaml:"idempotent_result_ttl" toml:"idempotent_result_ttl" doc:"Default TTL for idempotency cache entries. Default <<5m>>."`
|
||||
// BinaryData uses BYTEA columns instead of JSONB for data fields.
|
||||
// Set to true if data payloads are not valid JSON.
|
||||
BinaryData bool `mapstructure:"binary_data" json:"binary_data" envconfig:"binary_data" yaml:"binary_data" toml:"binary_data" doc:"Store data in BYTEA columns instead of JSONB. Enable when payloads are not valid JSON (e.g. binary or protobuf)."`
|
||||
// StreamRetention is the safety floor for HistoryMetaTTL when neither
|
||||
// PublishOptions nor node config sets it. Default: "24h". Guarantees
|
||||
// every channel meta row eventually expires.
|
||||
StreamRetention Duration `mapstructure:"stream_retention" json:"stream_retention" envconfig:"stream_retention" default:"24h" yaml:"stream_retention" toml:"stream_retention" doc:"Safety floor for history meta TTL when not set per publish or in node config, ensuring every channel meta row eventually expires. Default <<24h>>."`
|
||||
// UseNotify enables LISTEN/NOTIFY for low-latency outbox wakeup.
|
||||
UseNotify bool `mapstructure:"use_notify" json:"use_notify" envconfig:"use_notify" yaml:"use_notify" toml:"use_notify" doc:"Use PostgreSQL LISTEN/NOTIFY for low-latency outbox wakeup instead of polling only."`
|
||||
// NotifyDSN is an optional separate DSN for the LISTEN connection.
|
||||
// Required when DSN points at PGBouncer (transaction pooling mode is
|
||||
// incompatible with LISTEN/NOTIFY). Must be a direct PostgreSQL URL.
|
||||
NotifyDSN string `mapstructure:"notify_dsn" json:"notify_dsn" envconfig:"notify_dsn" yaml:"notify_dsn" toml:"notify_dsn" expose:"url" doc:"Optional separate DSN for the LISTEN connection. Required when the main DSN points at PGBouncer (transaction pooling is incompatible with LISTEN/NOTIFY); must be a direct PostgreSQL URL."`
|
||||
// TablePrefix is the namespace prefix for all tables created by this broker.
|
||||
// Default: "cf". Multi-tenant deployments sharing one PostgreSQL instance
|
||||
// use distinct prefixes per Centrifugo cluster (e.g. "prod_us_cf").
|
||||
TablePrefix string `mapstructure:"table_prefix" json:"table_prefix" envconfig:"table_prefix" default:"cf" yaml:"table_prefix" toml:"table_prefix" expose:"full" doc:"Namespace prefix for all tables created by this broker. Default <<cf>>. Use distinct prefixes per cluster when several Centrifugo deployments share one PostgreSQL instance."`
|
||||
// SkipSchemaInit disables automatic schema initialization on startup.
|
||||
SkipSchemaInit bool `mapstructure:"skip_schema_init" json:"skip_schema_init" envconfig:"skip_schema_init" yaml:"skip_schema_init" toml:"skip_schema_init" doc:"Disable automatic schema initialization on startup. When enabled, you must manage the schema externally (e.g. via migrations)."`
|
||||
// Outbox configures the outbox-based delivery mode.
|
||||
Outbox PostgresMapBrokerOutbox `mapstructure:"outbox" json:"outbox" envconfig:"outbox" yaml:"outbox" toml:"outbox" doc:"Outbox-based delivery configuration."`
|
||||
// PartitionLookaheadDays controls how many future daily partitions to
|
||||
// pre-create. Default: 2 (gives a 48-hour safety window).
|
||||
PartitionLookaheadDays int `mapstructure:"partition_lookahead_days" json:"partition_lookahead_days" envconfig:"partition_lookahead_days" default:"2" yaml:"partition_lookahead_days" toml:"partition_lookahead_days" doc:"How many future daily partitions to pre-create. Default <<2>> (a 48-hour safety window)."`
|
||||
// PartitionRetentionDays controls how old a partition can be before it
|
||||
// gets dropped whole by the partition retention worker. Default: 7.
|
||||
PartitionRetentionDays int `mapstructure:"partition_retention_days" json:"partition_retention_days" envconfig:"partition_retention_days" default:"7" yaml:"partition_retention_days" toml:"partition_retention_days" doc:"How many days a partition is kept before the retention worker drops it whole. Default <<7>>."`
|
||||
}
|
||||
|
||||
// Controller is a configuration for custom Centrifugo Controller.
|
||||
// In OSS, only "postgres" type is supported.
|
||||
type Controller struct {
|
||||
Enabled bool `mapstructure:"enabled" json:"enabled" envconfig:"enabled" yaml:"enabled" toml:"enabled" doc:"Enables the Centrifugo controller used for multi-node cluster coordination."`
|
||||
// Type of controller to use. Can be "postgres" in OSS. PRO also supports "redis" and "nats".
|
||||
Type string `mapstructure:"type" json:"type" envconfig:"type" yaml:"type" toml:"type" expose:"full" doc:"Controller backend type. In OSS only <<postgres>> is supported; PRO also supports <<redis>> and <<nats>>."`
|
||||
// Postgres is a configuration for "postgres" controller.
|
||||
Postgres PostgresController `mapstructure:"postgres" json:"postgres" envconfig:"postgres" toml:"postgres" yaml:"postgres" doc:"PostgreSQL controller configuration, used when type is <<postgres>>."`
|
||||
}
|
||||
|
||||
// PostgresController configures the PostgreSQL-based controller for multi-node
|
||||
// cluster coordination. Creates tables with the configured prefix
|
||||
// (e.g. cf_controller_messages, cf_controller_shard_lock).
|
||||
type PostgresController struct {
|
||||
// DSN is the primary PostgreSQL connection string.
|
||||
// Example: "postgres://user:pass@localhost:5432/dbname?sslmode=disable".
|
||||
DSN string `mapstructure:"dsn" json:"dsn" envconfig:"dsn" yaml:"dsn" toml:"dsn" expose:"url" doc:"Primary PostgreSQL connection string, e.g. <<postgres://user:pass@localhost:5432/dbname?sslmode=disable>>."`
|
||||
// TLS is an optional TLS configuration for all PostgreSQL connections.
|
||||
TLS TLSConfig `mapstructure:"tls" json:"tls" envconfig:"tls" yaml:"tls" toml:"tls" doc:"Optional TLS configuration applied to all PostgreSQL connections."`
|
||||
// PoolSize sets the maximum number of connections in the pool. Default: 8.
|
||||
PoolSize int `mapstructure:"pool_size" json:"pool_size" envconfig:"pool_size" default:"8" yaml:"pool_size" toml:"pool_size" doc:"Maximum number of connections in the pool. Default <<8>>."`
|
||||
// NumShards is the number of shards for serialized publishing. Default: 1.
|
||||
NumShards int `mapstructure:"num_shards" json:"num_shards" envconfig:"num_shards" default:"1" yaml:"num_shards" toml:"num_shards" doc:"Number of shards for serialized publishing. Default <<1>>."`
|
||||
// TablePrefix is the namespace prefix for all tables created by this controller.
|
||||
// Default: "cf". Produces names like cf_controller_messages.
|
||||
TablePrefix string `mapstructure:"table_prefix" json:"table_prefix" envconfig:"table_prefix" default:"cf" yaml:"table_prefix" toml:"table_prefix" expose:"full" doc:"Namespace prefix for all tables created by this controller. Default <<cf>> (produces names like cf_controller_messages)."`
|
||||
// PollInterval is how often to poll for new control messages when idle. Default: "50ms".
|
||||
PollInterval Duration `mapstructure:"poll_interval" json:"poll_interval" envconfig:"poll_interval" default:"50ms" yaml:"poll_interval" toml:"poll_interval" doc:"How often to poll for new control messages when idle. Default <<50ms>>."`
|
||||
// UseNotify enables LISTEN/NOTIFY for low-latency wakeup. Default: true.
|
||||
UseNotify bool `mapstructure:"use_notify" json:"use_notify" envconfig:"use_notify" yaml:"use_notify" toml:"use_notify" doc:"Use PostgreSQL LISTEN/NOTIFY for low-latency wakeup instead of polling only. Default <<true>>."`
|
||||
// NotifyDSN is an optional separate DSN for the LISTEN connection.
|
||||
// Required when DSN points at PGBouncer (transaction pooling mode is
|
||||
// incompatible with LISTEN/NOTIFY). Must be a direct PostgreSQL URL.
|
||||
NotifyDSN string `mapstructure:"notify_dsn" json:"notify_dsn" envconfig:"notify_dsn" yaml:"notify_dsn" toml:"notify_dsn" expose:"url" doc:"Optional separate DSN for the LISTEN connection. Required when the main DSN points at PGBouncer (transaction pooling is incompatible with LISTEN/NOTIFY); must be a direct PostgreSQL URL."`
|
||||
// PartitionRetentionDays controls how old a partition must be before it is
|
||||
// dropped. Default: 1 (control messages are ephemeral).
|
||||
PartitionRetentionDays int `mapstructure:"partition_retention_days" json:"partition_retention_days" envconfig:"partition_retention_days" default:"1" yaml:"partition_retention_days" toml:"partition_retention_days" doc:"How many days a partition is kept before it is dropped. Default <<1>> (control messages are ephemeral)."`
|
||||
// PartitionLookaheadDays controls how many future daily partitions to
|
||||
// pre-create. Default: 2.
|
||||
PartitionLookaheadDays int `mapstructure:"partition_lookahead_days" json:"partition_lookahead_days" envconfig:"partition_lookahead_days" default:"2" yaml:"partition_lookahead_days" toml:"partition_lookahead_days" doc:"How many future daily partitions to pre-create. Default <<2>>."`
|
||||
// PartitionCleanupInterval is how often the partition worker ticks. Default: "1m".
|
||||
PartitionCleanupInterval Duration `mapstructure:"partition_cleanup_interval" json:"partition_cleanup_interval" envconfig:"partition_cleanup_interval" default:"1m" yaml:"partition_cleanup_interval" toml:"partition_cleanup_interval" doc:"How often the partition worker runs. Default <<1m>>."`
|
||||
// BatchSize is the maximum number of rows to process per poll batch. Default: 1000.
|
||||
BatchSize int `mapstructure:"batch_size" json:"batch_size" envconfig:"batch_size" default:"1000" yaml:"batch_size" toml:"batch_size" doc:"Maximum number of rows to process per poll batch. Default <<1000>>."`
|
||||
// SkipSchemaInit disables automatic schema initialization on startup.
|
||||
// When true, the schema must be managed externally (e.g. via migrations).
|
||||
SkipSchemaInit bool `mapstructure:"skip_schema_init" json:"skip_schema_init" envconfig:"skip_schema_init" yaml:"skip_schema_init" toml:"skip_schema_init" doc:"Disable automatic schema initialization on startup. When enabled, you must manage the schema externally (e.g. via migrations)."`
|
||||
}
|
||||
103
internal/configtypes/fieldmeta.go
Normal file
103
internal/configtypes/fieldmeta.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package configtypes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file hosts small reflection helpers shared by config tooling that walks
|
||||
// the configuration structs — currently the `configdoc` generator and the admin
|
||||
// config redaction (Pro). Keeping them here, in OSS, gives both a single source
|
||||
// of truth for how config fields are classified, named, and flattened. See also
|
||||
// NameForEnv in namespace.go.
|
||||
|
||||
// JSONKey returns the configured key for a struct field: the first segment of the
|
||||
// json tag (falling back to the mapstructure tag), or the Go field name when no
|
||||
// tag is present.
|
||||
func JSONKey(f reflect.StructField) string {
|
||||
tag := f.Tag.Get("json")
|
||||
if tag == "" {
|
||||
tag = f.Tag.Get("mapstructure")
|
||||
}
|
||||
if tag == "" {
|
||||
return f.Name
|
||||
}
|
||||
if name := strings.Split(tag, ",")[0]; name != "" {
|
||||
return name
|
||||
}
|
||||
return f.Name
|
||||
}
|
||||
|
||||
// IsSquash reports whether a struct field is squashed/inlined into its parent
|
||||
// (an embedded struct, a mapstructure ",squash", or a yaml ",inline"). Such
|
||||
// fields contribute their own fields to the parent rather than a nested object.
|
||||
func IsSquash(f reflect.StructField) bool {
|
||||
return f.Anonymous ||
|
||||
strings.Contains(f.Tag.Get("mapstructure"), "squash") ||
|
||||
strings.Contains(f.Tag.Get("yaml"), "inline")
|
||||
}
|
||||
|
||||
// IsComplexType reports whether t is object-like — a struct or a slice/array of
|
||||
// structs — and therefore cannot be populated from a single environment variable
|
||||
// (its keys are set separately). Scalars, slices of scalars, and maps are not
|
||||
// complex (each maps to one env var). time.Time is treated as a scalar.
|
||||
func IsComplexType(t reflect.Type) bool {
|
||||
switch t.Kind() {
|
||||
case reflect.Pointer:
|
||||
return t.Elem().Kind() == reflect.Struct && !isTimeType(t.Elem())
|
||||
case reflect.Struct:
|
||||
return !isTimeType(t)
|
||||
case reflect.Slice, reflect.Array:
|
||||
elem := t.Elem()
|
||||
if elem.Kind() == reflect.Pointer {
|
||||
elem = elem.Elem()
|
||||
}
|
||||
return elem.Kind() == reflect.Struct && !isTimeType(elem)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeType(t reflect.Type) bool {
|
||||
return t.PkgPath() == "time" && t.Name() == "Time"
|
||||
}
|
||||
|
||||
// TypeLabel renders a human-friendly type label for a config field type. Struct
|
||||
// and slice-of-struct types show their Go type name (e.g. "TLSConfig",
|
||||
// "[]Consumer"); every other type shows its Go type string with the internal
|
||||
// "configtypes." package qualifier stripped (e.g. "Duration", "PEMData", "int64",
|
||||
// "[]string", "map[string]string"). Used by both the configdoc generator and the
|
||||
// admin config UI (Pro) so the two surfaces present identical type labels.
|
||||
func TypeLabel(t reflect.Type) string {
|
||||
if t.Kind() == reflect.Pointer {
|
||||
if t.Elem().Kind() == reflect.Struct {
|
||||
if isTimeType(t.Elem()) {
|
||||
return stripConfigtypesQualifier(t.String())
|
||||
}
|
||||
return t.Elem().Name()
|
||||
}
|
||||
}
|
||||
if t.Kind() == reflect.Struct {
|
||||
if isTimeType(t) {
|
||||
return stripConfigtypesQualifier(t.String())
|
||||
}
|
||||
return t.Name()
|
||||
}
|
||||
if t.Kind() == reflect.Slice {
|
||||
elem := t.Elem()
|
||||
if elem.Kind() == reflect.Pointer {
|
||||
elem = elem.Elem()
|
||||
}
|
||||
if elem.Kind() == reflect.Struct {
|
||||
if isTimeType(elem) {
|
||||
return stripConfigtypesQualifier(t.String())
|
||||
}
|
||||
return "[]" + elem.Name()
|
||||
}
|
||||
}
|
||||
return stripConfigtypesQualifier(t.String())
|
||||
}
|
||||
|
||||
func stripConfigtypesQualifier(typeStr string) string {
|
||||
return strings.ReplaceAll(typeStr, "configtypes.", "")
|
||||
}
|
||||
97
internal/configtypes/fieldmeta_test.go
Normal file
97
internal/configtypes/fieldmeta_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package configtypes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fmInner struct {
|
||||
X int `json:"x"`
|
||||
}
|
||||
|
||||
type fmEmbedded struct {
|
||||
Z int `json:"z"`
|
||||
}
|
||||
|
||||
func TestTypeLabel(t *testing.T) {
|
||||
cases := []struct {
|
||||
val any
|
||||
want string
|
||||
}{
|
||||
{"", "string"},
|
||||
{true, "bool"},
|
||||
{0, "int"},
|
||||
{int32(0), "int32"},
|
||||
{int64(0), "int64"},
|
||||
{uint32(0), "uint32"},
|
||||
{float64(0), "float64"},
|
||||
{Duration(0), "Duration"}, // named numeric type kept faithful
|
||||
{PEMData(""), "PEMData"}, // named string type kept faithful
|
||||
{[]string{}, "[]string"}, // slice of scalars
|
||||
{[]Duration{}, "[]Duration"},
|
||||
{map[string]string{}, "map[string]string"},
|
||||
{fmInner{}, "fmInner"}, // struct → type name
|
||||
{&fmInner{}, "fmInner"}, // pointer to struct → element name
|
||||
{[]fmInner{}, "[]fmInner"}, // slice of structs → []name
|
||||
{time.Time{}, "time.Time"}, // time is rendered with its qualifier
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := TypeLabel(reflect.TypeOf(c.val)); got != c.want {
|
||||
t.Errorf("TypeLabel(%T) = %q, want %q", c.val, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsComplexType(t *testing.T) {
|
||||
complexVals := []any{fmInner{}, &fmInner{}, []fmInner{}, []*fmInner{}}
|
||||
simpleVals := []any{"", true, 0, int64(0), float64(0), Duration(0), PEMData(""), []string{}, map[string]string{}, time.Time{}}
|
||||
|
||||
for _, v := range complexVals {
|
||||
if !IsComplexType(reflect.TypeOf(v)) {
|
||||
t.Errorf("IsComplexType(%T) = false, want true", v)
|
||||
}
|
||||
}
|
||||
for _, v := range simpleVals {
|
||||
if IsComplexType(reflect.TypeOf(v)) {
|
||||
t.Errorf("IsComplexType(%T) = true, want false", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONKey(t *testing.T) {
|
||||
type s struct {
|
||||
A string `json:"a_field" mapstructure:"a_field"`
|
||||
B string `mapstructure:"b_field"` // no json → mapstructure fallback
|
||||
C string // no tags → field name
|
||||
D string `json:"d_field,omitempty"` // options stripped
|
||||
}
|
||||
rt := reflect.TypeOf(s{})
|
||||
want := map[string]string{"A": "a_field", "B": "b_field", "C": "C", "D": "d_field"}
|
||||
for name, w := range want {
|
||||
f, _ := rt.FieldByName(name)
|
||||
if got := JSONKey(f); got != w {
|
||||
t.Errorf("JSONKey(%s) = %q, want %q", name, got, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSquash(t *testing.T) {
|
||||
type s struct {
|
||||
fmEmbedded // anonymous → squash
|
||||
Inner fmInner `mapstructure:",squash"` // mapstructure squash
|
||||
Inline fmInner `yaml:",inline"` // yaml inline
|
||||
Plain string `json:"plain"` // not squash
|
||||
}
|
||||
rt := reflect.TypeOf(s{})
|
||||
squashed := []string{"fmEmbedded", "Inner", "Inline"}
|
||||
for _, name := range squashed {
|
||||
f, _ := rt.FieldByName(name)
|
||||
if !IsSquash(f) {
|
||||
t.Errorf("IsSquash(%s) = false, want true", name)
|
||||
}
|
||||
}
|
||||
if f, _ := rt.FieldByName("Plain"); IsSquash(f) {
|
||||
t.Errorf("IsSquash(Plain) = true, want false")
|
||||
}
|
||||
}
|
||||
39
internal/configtypes/kafka_consumer_test.go
Normal file
39
internal/configtypes/kafka_consumer_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package configtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKafkaConsumerConfigValidate_AssumeRoleARN(t *testing.T) {
|
||||
base := KafkaConsumerConfig{
|
||||
Brokers: []string{"localhost:9092"},
|
||||
Topics: []string{"t"},
|
||||
ConsumerGroup: "g",
|
||||
}
|
||||
|
||||
t.Run("assume_role_arn with aws-msk-iam is valid", func(t *testing.T) {
|
||||
c := base
|
||||
c.SASLMechanism = "aws-msk-iam"
|
||||
c.AssumeRoleARN = "arn:aws:iam::123456789012:role/MSKAccess"
|
||||
require.NoError(t, c.Validate())
|
||||
})
|
||||
|
||||
t.Run("assume_role_arn without aws-msk-iam is invalid", func(t *testing.T) {
|
||||
c := base
|
||||
c.SASLMechanism = "plain"
|
||||
c.AssumeRoleARN = "arn:aws:iam::123456789012:role/MSKAccess"
|
||||
err := c.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "assume_role_arn requires sasl_mechanism aws-msk-iam")
|
||||
})
|
||||
|
||||
t.Run("assume_role_arn with empty sasl_mechanism is invalid", func(t *testing.T) {
|
||||
c := base
|
||||
c.AssumeRoleARN = "arn:aws:iam::123456789012:role/MSKAccess"
|
||||
err := c.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "assume_role_arn requires sasl_mechanism aws-msk-iam")
|
||||
})
|
||||
}
|
||||
258
internal/configtypes/namespace.go
Normal file
258
internal/configtypes/namespace.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package configtypes
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
)
|
||||
|
||||
const (
|
||||
// PublicationDataFormatJSON indicates that publication data must be valid JSON.
|
||||
PublicationDataFormatJSON = "json"
|
||||
// PublicationDataFormatJSONObject indicates that publication data must be valid JSON and specifically a JSON object.
|
||||
PublicationDataFormatJSONObject = "json_object"
|
||||
// PublicationDataFormatBinary indicates that publication data is binary and empty data is allowed.
|
||||
PublicationDataFormatBinary = "binary"
|
||||
)
|
||||
|
||||
type ChannelNamespaces []ChannelNamespace
|
||||
|
||||
// Decode to implement the envconfig.Decoder interface
|
||||
func (d *ChannelNamespaces) Decode(value string) error {
|
||||
return decodeToNamedSlice(value, d)
|
||||
}
|
||||
|
||||
// ChannelNamespace allows creating channels with different channel options.
|
||||
type ChannelNamespace struct {
|
||||
// Name is a unique namespace name.
|
||||
Name string `mapstructure:"name" json:"name" envconfig:"name" yaml:"name" toml:"name" expose:"full" doc:"Unique namespace name. Channels prefixed with <<name:>> use this namespace's options."`
|
||||
|
||||
// Options for namespace determine channel options for channels
|
||||
// belonging to this namespace.
|
||||
ChannelOptions `mapstructure:",squash" yaml:",inline"`
|
||||
}
|
||||
|
||||
func (o ChannelOptions) GetRecoveryMode() centrifuge.RecoveryMode {
|
||||
if o.ForceRecoveryMode == "cache" {
|
||||
return centrifuge.RecoveryModeCache
|
||||
}
|
||||
return centrifuge.RecoveryModeStream
|
||||
}
|
||||
|
||||
func NameForEnv(input string) string {
|
||||
// Create a new replacer to replace '.' and '-' with '_'
|
||||
replacer := strings.NewReplacer(".", "_", "-", "_")
|
||||
return replacer.Replace(input)
|
||||
}
|
||||
|
||||
// ChannelOptions represent channel specific configuration for namespace
|
||||
// or global channel options if set on top level of configuration.
|
||||
type ChannelOptions struct {
|
||||
// Presence turns on presence information for channel. Presence has
|
||||
// information about all clients currently subscribed to a channel.
|
||||
Presence bool `mapstructure:"presence" json:"presence" envconfig:"presence" yaml:"presence" toml:"presence" doc:"Enables presence information for channels in this namespace — the set of clients currently subscribed. Adds storage and CPU overhead, so avoid on channels with very many subscribers."`
|
||||
|
||||
// JoinLeave turns on join/leave messages for a channel.
|
||||
// When client subscribes on a channel join message sent to all
|
||||
// subscribers in this channel (including current client). When client
|
||||
// leaves channel (unsubscribes) leave message sent. This option does
|
||||
// not fit well for channels with many subscribers because every
|
||||
// subscribe/unsubscribe event results into join/leave event broadcast
|
||||
// to all other active subscribers thus overloads server with tons of
|
||||
// messages. Use accurately for channels with small number of active
|
||||
// subscribers.
|
||||
JoinLeave bool `mapstructure:"join_leave" json:"join_leave" envconfig:"join_leave" yaml:"join_leave" toml:"join_leave" doc:"Enables join/leave messages broadcast to subscribers when clients subscribe or unsubscribe. Each event is sent to all subscribers, so avoid on channels with many subscribers."`
|
||||
|
||||
// ForcePushJoinLeave forces sending join/leave messages towards subscribers.
|
||||
ForcePushJoinLeave bool `mapstructure:"force_push_join_leave" json:"force_push_join_leave" envconfig:"force_push_join_leave" yaml:"force_push_join_leave" toml:"force_push_join_leave" doc:"Forces pushing join/leave messages to subscribers even if they did not request them on subscribe."`
|
||||
|
||||
// MapClientsPresenceChannelPrefix is a prefix for map-based client presence channel.
|
||||
// When set, client presence is tracked via MapBroker in a channel formed by this prefix + original channel name.
|
||||
MapClientsPresenceChannelPrefix string `mapstructure:"map_clients_presence_channel_prefix" json:"map_clients_presence_channel_prefix" envconfig:"map_clients_presence_channel_prefix" yaml:"map_clients_presence_channel_prefix" toml:"map_clients_presence_channel_prefix" expose:"full" doc:"Prefix for the map-based client presence channel. When set, client presence is tracked via MapBroker in a channel formed as this prefix plus the original channel name."`
|
||||
|
||||
// MapUsersPresenceChannelPrefix is a prefix for map-based user presence channel.
|
||||
// When set, user presence is tracked via MapBroker in a channel formed by this prefix + original channel name.
|
||||
MapUsersPresenceChannelPrefix string `mapstructure:"map_users_presence_channel_prefix" json:"map_users_presence_channel_prefix" envconfig:"map_users_presence_channel_prefix" yaml:"map_users_presence_channel_prefix" toml:"map_users_presence_channel_prefix" expose:"full" doc:"Prefix for the map-based user presence channel. When set, user presence is tracked via MapBroker in a channel formed as this prefix plus the original channel name."`
|
||||
|
||||
// HistorySize determines max amount of history messages for a channel,
|
||||
// Zero value means no history for channel. Centrifuge history has an
|
||||
// auxiliary role with current Engines – it can not replace your backend
|
||||
// persistent storage.
|
||||
HistorySize int `mapstructure:"history_size" json:"history_size" envconfig:"history_size" yaml:"history_size" toml:"history_size" doc:"Maximum number of messages kept in channel history. Zero (default) disables history. History is auxiliary and must not replace your persistent storage."`
|
||||
|
||||
// HistoryTTL is a time to live for history cache. Server maintains a window of
|
||||
// messages in memory (or in Redis with Redis engine), to prevent infinite memory
|
||||
// grows it's important to remove history for inactive channels.
|
||||
HistoryTTL Duration `mapstructure:"history_ttl" json:"history_ttl" envconfig:"history_ttl" yaml:"history_ttl" toml:"history_ttl" doc:"How long messages are retained in channel history, e.g. <<300s>>. Keep it bounded so history for inactive channels is freed."`
|
||||
|
||||
// HistoryMetaTTL is a time to live for history stream meta information. Must be
|
||||
// much larger than HistoryTTL in common scenario. If zero, then we use global value
|
||||
// set over default_history_meta_ttl on configuration top level.
|
||||
HistoryMetaTTL Duration `mapstructure:"history_meta_ttl" json:"history_meta_ttl" envconfig:"history_meta_ttl" yaml:"history_meta_ttl" toml:"history_meta_ttl" doc:"How long history stream meta information (offset, epoch) is retained. Should be much larger than history_ttl. If zero, the global default_history_meta_ttl is used."`
|
||||
|
||||
// ForcePositioning enables client positioning. This means that StreamPosition
|
||||
// will be exposed to the client and server will look that no messages from
|
||||
// PUB/SUB layer lost. In the loss found – client is disconnected (or unsubscribed)
|
||||
// with reconnect (resubscribe) code.
|
||||
ForcePositioning bool `mapstructure:"force_positioning" json:"force_positioning" envconfig:"force_positioning" yaml:"force_positioning" toml:"force_positioning" doc:"Forces positioning for all subscribers — Centrifugo checks no messages are lost in the PUB/SUB layer and disconnects the client with reconnect on detected loss. Requires reasonable history_size and history_ttl."`
|
||||
|
||||
// AllowPositioning allows positioning when client asks about it.
|
||||
AllowPositioning bool `mapstructure:"allow_positioning" json:"allow_positioning" envconfig:"allow_positioning" yaml:"allow_positioning" toml:"allow_positioning" doc:"Allows clients to enable positioning on their own when subscribing. Requires reasonable history_size and history_ttl."`
|
||||
|
||||
// ForceRecovery enables recovery mechanism for channels. This means that
|
||||
// server will try to recover missed messages for resubscribing client.
|
||||
// This option uses publications from history and must be used with reasonable
|
||||
// HistorySize and HistoryTTL configuration.
|
||||
ForceRecovery bool `mapstructure:"force_recovery" json:"force_recovery" envconfig:"force_recovery" yaml:"force_recovery" toml:"force_recovery" doc:"Forces recovery of missed messages for resubscribing clients using channel history. Requires reasonable history_size and history_ttl."`
|
||||
|
||||
// AllowRecovery allows recovery when client asks about it.
|
||||
AllowRecovery bool `mapstructure:"allow_recovery" json:"allow_recovery" envconfig:"allow_recovery" yaml:"allow_recovery" toml:"allow_recovery" doc:"Allows clients to enable recovery on their own when subscribing. Requires reasonable history_size and history_ttl."`
|
||||
|
||||
// ForceRecoveryMode can set the recovery mode for all channel subscribers in the namespace which use recovery.
|
||||
ForceRecoveryMode string `mapstructure:"force_recovery_mode" json:"force_recovery_mode" envconfig:"force_recovery_mode" yaml:"force_recovery_mode" toml:"force_recovery_mode" expose:"full" doc:"Recovery mode for subscribers in this namespace that use recovery. Use <<stream>> (default) or <<cache>> (only the latest publication is recovered)."`
|
||||
|
||||
// AutoCacheRecover makes Centrifugo automatically recover all subscriptions in the namespace
|
||||
// on subscribe, without requiring the subscriber to request recovery itself. In cache recovery
|
||||
// mode this means the latest publication is delivered on every (re)subscribe without the need
|
||||
// to provide an empty "since" on the client side, and it also enables this delivery for
|
||||
// server-side subscriptions (e.g. of unidirectional clients which may not even know channel
|
||||
// names). Requires force_recovery and force_recovery_mode set to "cache".
|
||||
AutoCacheRecover bool `mapstructure:"auto_cache_recover" json:"auto_cache_recover" envconfig:"auto_cache_recover" yaml:"auto_cache_recover" toml:"auto_cache_recover" doc:"Automatically recovers all subscriptions in the namespace on subscribe without the subscriber requesting recovery. In cache recovery mode delivers the latest publication on every (re)subscribe (no client-side empty \"since\" needed) and also works for server-side subscriptions of unidirectional clients. Requires force_recovery and force_recovery_mode=cache."`
|
||||
|
||||
// AllowedDeltaTypes is non-empty contains slice of allowed delta types for subscribers to use.
|
||||
AllowedDeltaTypes []centrifuge.DeltaType `mapstructure:"allowed_delta_types" json:"allowed_delta_types" envconfig:"allowed_delta_types" yaml:"allowed_delta_types" toml:"allowed_delta_types" doc:"Delta types subscribers may request in this namespace, e.g. <<[\"fossil\"]>>. Empty disables delta compression."`
|
||||
|
||||
// AllowTagsFilter enables tags filtering for channels in namespace. Clients can pass tags filter in subscribe request.
|
||||
// When tags filter is set only messages with matching tags will be delivered to the client.
|
||||
AllowTagsFilter bool `mapstructure:"allow_tags_filter" json:"allow_tags_filter" envconfig:"allow_tags_filter" yaml:"allow_tags_filter" toml:"allow_tags_filter" doc:"Allows clients to pass a tags filter on subscribe so only publications with matching tags are delivered to them."`
|
||||
|
||||
// DeltaPublish enables delta publish mechanism for all messages published in namespace channels
|
||||
// without explicit flag usage in publish API request. Setting this option does not guarantee that
|
||||
// publication will be compressed when going towards subscribers – it still depends on subscriber
|
||||
// connection options and whether Centrifugo Node is able to find previous publication in channel.
|
||||
DeltaPublish bool `mapstructure:"delta_publish" json:"delta_publish" envconfig:"delta_publish" yaml:"delta_publish" toml:"delta_publish" doc:"Enables delta publish for all messages in the namespace without setting the flag per publish call. Actual compression still depends on subscriber connection options."`
|
||||
|
||||
// SubscribeForAnonymous allows anonymous clients to subscribe on channels in namespace.
|
||||
SubscribeForAnonymous bool `mapstructure:"allow_subscribe_for_anonymous" json:"allow_subscribe_for_anonymous" envconfig:"allow_subscribe_for_anonymous" yaml:"allow_subscribe_for_anonymous" toml:"allow_subscribe_for_anonymous" doc:"Allows anonymous (empty user ID) clients to subscribe to channels in this namespace."`
|
||||
|
||||
// SubscribeForClient allows authenticated clients to subscribe on channels in namespace.
|
||||
SubscribeForClient bool `mapstructure:"allow_subscribe_for_client" json:"allow_subscribe_for_client" envconfig:"allow_subscribe_for_client" yaml:"allow_subscribe_for_client" toml:"allow_subscribe_for_client" doc:"Allows authenticated (non-anonymous) clients to subscribe to channels in this namespace."`
|
||||
|
||||
// PublishForAnonymous allows anonymous clients to publish messages into channels in namespace.
|
||||
PublishForAnonymous bool `mapstructure:"allow_publish_for_anonymous" json:"allow_publish_for_anonymous" envconfig:"allow_publish_for_anonymous" yaml:"allow_publish_for_anonymous" toml:"allow_publish_for_anonymous" doc:"Allows anonymous clients to publish directly into channels in this namespace. Enabling client-side publish bypasses your backend — use with care."`
|
||||
|
||||
// PublishForSubscriber allows clients subscribed on channel to publish messages into it.
|
||||
PublishForSubscriber bool `mapstructure:"allow_publish_for_subscriber" json:"allow_publish_for_subscriber" envconfig:"allow_publish_for_subscriber" yaml:"allow_publish_for_subscriber" toml:"allow_publish_for_subscriber" doc:"Allows clients to publish into channels they are subscribed to. Client-side publish bypasses your backend — use with care."`
|
||||
|
||||
// PublishForClient allows authenticated clients to publish messages into channels in namespace.
|
||||
PublishForClient bool `mapstructure:"allow_publish_for_client" json:"allow_publish_for_client" envconfig:"allow_publish_for_client" yaml:"allow_publish_for_client" toml:"allow_publish_for_client" doc:"Allows authenticated clients to publish into channels in this namespace even without being subscribed. Client-side publish bypasses your backend — use with care."`
|
||||
|
||||
// PresenceForAnonymous allows anonymous clients to get presence information for channels in namespace.
|
||||
PresenceForAnonymous bool `mapstructure:"allow_presence_for_anonymous" json:"allow_presence_for_anonymous" envconfig:"allow_presence_for_anonymous" yaml:"allow_presence_for_anonymous" toml:"allow_presence_for_anonymous" doc:"Allows anonymous clients to call presence on channels in this namespace. Requires presence to be enabled."`
|
||||
|
||||
// PresenceForSubscriber allows clients subscribed on channel to get presence information for it.
|
||||
PresenceForSubscriber bool `mapstructure:"allow_presence_for_subscriber" json:"allow_presence_for_subscriber" envconfig:"allow_presence_for_subscriber" yaml:"allow_presence_for_subscriber" toml:"allow_presence_for_subscriber" doc:"Allows clients to call presence on channels they are subscribed to. Requires presence to be enabled."`
|
||||
|
||||
// PresenceForClient allows authenticated clients to get presence information for channels in namespace.
|
||||
PresenceForClient bool `mapstructure:"allow_presence_for_client" json:"allow_presence_for_client" envconfig:"allow_presence_for_client" yaml:"allow_presence_for_client" toml:"allow_presence_for_client" doc:"Allows authenticated clients to call presence on channels in this namespace even without being subscribed. Requires presence to be enabled."`
|
||||
|
||||
// HistoryForAnonymous allows anonymous clients to get history information for channels in namespace.
|
||||
HistoryForAnonymous bool `mapstructure:"allow_history_for_anonymous" json:"allow_history_for_anonymous" envconfig:"allow_history_for_anonymous" yaml:"allow_history_for_anonymous" toml:"allow_history_for_anonymous" doc:"Allows anonymous clients to call history on channels in this namespace. Requires history to be enabled."`
|
||||
|
||||
// HistoryForSubscriber allows clients subscribed on channel to get history information for it.
|
||||
HistoryForSubscriber bool `mapstructure:"allow_history_for_subscriber" json:"allow_history_for_subscriber" envconfig:"allow_history_for_subscriber" yaml:"allow_history_for_subscriber" toml:"allow_history_for_subscriber" doc:"Allows clients to call history on channels they are subscribed to. Requires history to be enabled."`
|
||||
|
||||
// HistoryForClient allows authenticated clients to get history information for channels in namespace.
|
||||
HistoryForClient bool `mapstructure:"allow_history_for_client" json:"allow_history_for_client" envconfig:"allow_history_for_client" yaml:"allow_history_for_client" toml:"allow_history_for_client" doc:"Allows authenticated clients to call history on channels in this namespace even without being subscribed. Requires history to be enabled."`
|
||||
|
||||
// UserLimitedChannels allows to limit number of channels user can subscribe to in namespace.
|
||||
UserLimitedChannels bool `mapstructure:"allow_user_limited_channels" json:"allow_user_limited_channels" envconfig:"allow_user_limited_channels" yaml:"allow_user_limited_channels" toml:"allow_user_limited_channels" doc:"Allows user-limited channels in this namespace — channels containing a <<#>> with a comma-separated list of user IDs allowed to subscribe."`
|
||||
|
||||
// ChannelRegex sets a regular expression to check channel name against.
|
||||
ChannelRegex string `mapstructure:"channel_regex" json:"channel_regex" envconfig:"channel_regex" yaml:"channel_regex" toml:"channel_regex" expose:"full" doc:"Regular expression that channel names in this namespace (the part after the namespace prefix) must match. Empty disables the check."`
|
||||
|
||||
// PublicationDataFormat defines the format validation for publication data.
|
||||
// If empty (default) - current behavior is used, reject empty data.
|
||||
// If "json" - validate that data is valid JSON, return bad request if not.
|
||||
// If "json_object" - validate that data is valid JSON and specifically a JSON object, return bad request if not.
|
||||
// If "binary" - allow empty data to be published.
|
||||
PublicationDataFormat string `mapstructure:"publication_data_format" json:"publication_data_format" envconfig:"publication_data_format" yaml:"publication_data_format" toml:"publication_data_format" expose:"full" doc:"Validation applied to publication data. Empty (default) rejects empty data; <<json>> requires valid JSON; <<json_object>> requires a JSON object; <<binary>> allows empty/binary data."`
|
||||
|
||||
// SubscribeProxyEnabled turns on using proxy for subscribe operations in namespace.
|
||||
SubscribeProxyEnabled bool `mapstructure:"subscribe_proxy_enabled" json:"subscribe_proxy_enabled" envconfig:"subscribe_proxy_enabled" yaml:"subscribe_proxy_enabled" toml:"subscribe_proxy_enabled" doc:"Proxies subscribe events in this namespace to your backend for authorization. Requires a configured subscribe proxy."`
|
||||
// SubscribeProxyName of proxy to use for subscribe operations in namespace.
|
||||
SubscribeProxyName string `mapstructure:"subscribe_proxy_name" default:"default" json:"subscribe_proxy_name" envconfig:"subscribe_proxy_name" yaml:"subscribe_proxy_name" toml:"subscribe_proxy_name" expose:"full" doc:"Name of the configured proxy to use for subscribe events in this namespace. Defaults to <<default>>."`
|
||||
|
||||
// PublishProxyEnabled turns on using proxy for publish operations in namespace.
|
||||
PublishProxyEnabled bool `mapstructure:"publish_proxy_enabled" json:"publish_proxy_enabled" envconfig:"publish_proxy_enabled" yaml:"publish_proxy_enabled" toml:"publish_proxy_enabled" doc:"Proxies client publish events in this namespace to your backend for authorization or transformation. Requires a configured publish proxy."`
|
||||
// PublishProxyName of proxy to use for publish operations in namespace.
|
||||
PublishProxyName string `mapstructure:"publish_proxy_name" default:"default" json:"publish_proxy_name" envconfig:"publish_proxy_name" yaml:"publish_proxy_name" toml:"publish_proxy_name" expose:"full" doc:"Name of the configured proxy to use for publish events in this namespace. Defaults to <<default>>."`
|
||||
|
||||
// SubRefreshProxyEnabled turns on using proxy for sub refresh operations in namespace.
|
||||
SubRefreshProxyEnabled bool `mapstructure:"sub_refresh_proxy_enabled" json:"sub_refresh_proxy_enabled" envconfig:"sub_refresh_proxy_enabled" yaml:"sub_refresh_proxy_enabled" toml:"sub_refresh_proxy_enabled" doc:"Proxies subscription refresh events in this namespace to your backend to validate and prolong subscriptions. Requires a configured sub refresh proxy."`
|
||||
// SubRefreshProxyName of proxy to use for sub refresh operations in namespace.
|
||||
SubRefreshProxyName string `mapstructure:"sub_refresh_proxy_name" default:"default" json:"sub_refresh_proxy_name" envconfig:"sub_refresh_proxy_name" yaml:"sub_refresh_proxy_name" toml:"sub_refresh_proxy_name" expose:"full" doc:"Name of the configured proxy to use for subscription refresh events in this namespace. Defaults to <<default>>."`
|
||||
|
||||
// SubscribeStreamProxyEnabled turns on using proxy for subscribe stream operations in namespace.
|
||||
SubscribeStreamProxyEnabled bool `mapstructure:"subscribe_stream_proxy_enabled" json:"subscribe_stream_proxy_enabled" envconfig:"subscribe_stream_proxy_enabled" yaml:"subscribe_stream_proxy_enabled" toml:"subscribe_stream_proxy_enabled" doc:"Proxies subscriptions in this namespace to a stream proxy that streams publications from your backend instead of the PUB/SUB engine. Requires a configured subscribe stream proxy."`
|
||||
// SubscribeStreamProxyName of proxy to use for subscribe stream operations in namespace.
|
||||
SubscribeStreamProxyName string `mapstructure:"subscribe_stream_proxy_name" default:"default" json:"subscribe_stream_proxy_name" envconfig:"subscribe_stream_proxy_name" yaml:"subscribe_stream_proxy_name" toml:"subscribe_stream_proxy_name" expose:"full" doc:"Name of the configured proxy to use for subscribe stream in this namespace. Defaults to <<default>>."`
|
||||
// SubscribeStreamBidirectional enables using bidirectional stream proxy for the namespace.
|
||||
SubscribeStreamBidirectional bool `mapstructure:"subscribe_stream_proxy_bidirectional" json:"subscribe_stream_proxy_bidirectional" envconfig:"subscribe_stream_proxy_bidirectional" yaml:"subscribe_stream_proxy_bidirectional" toml:"subscribe_stream_proxy_bidirectional" doc:"Enables bidirectional mode for the subscribe stream proxy, letting clients also send data into the stream."`
|
||||
|
||||
// SubscriptionType defines the subscription type for the namespace.
|
||||
// Valid values: "stream", "map", "map_clients", "map_users", "shared_poll".
|
||||
// Default: "stream" (traditional PUB/SUB with history).
|
||||
SubscriptionType string `mapstructure:"subscription_type" json:"subscription_type" envconfig:"subscription_type" yaml:"subscription_type" toml:"subscription_type" expose:"full" doc:"Subscription mechanism for the namespace. One of <<stream>> (default, PUB/SUB with history), <<map>>, <<map_clients>>, <<map_users>>, or <<shared_poll>>."`
|
||||
|
||||
// Map contains configuration for map subscription types (map, map_clients, map_users).
|
||||
Map MapConfig `mapstructure:"map" json:"map" envconfig:"map" yaml:"map" toml:"map" doc:"Configuration for the map subscription types (<<map>>, <<map_clients>>, <<map_users>>)."`
|
||||
|
||||
// SharedPoll contains configuration for shared poll subscription type.
|
||||
SharedPoll SharedPollConfig `mapstructure:"shared_poll" json:"shared_poll" envconfig:"shared_poll" yaml:"shared_poll" toml:"shared_poll" doc:"Configuration for the <<shared_poll>> subscription type."`
|
||||
|
||||
Compiled `json:"-" yaml:"-" toml:"-"`
|
||||
}
|
||||
|
||||
// MapConfig contains configuration for map subscription types (map, map_clients, map_users).
|
||||
type MapConfig struct {
|
||||
Mode string `mapstructure:"mode" json:"mode" envconfig:"mode" yaml:"mode" toml:"mode" expose:"full" doc:"Map mode controlling how keys are stored and delivered. See map subscription docs for supported values."`
|
||||
KeyTTL Duration `mapstructure:"key_ttl" json:"key_ttl" envconfig:"key_ttl" yaml:"key_ttl" toml:"key_ttl" doc:"How long a key is retained in the map after its last update, e.g. <<300s>>. Zero means no expiration."`
|
||||
StreamSize int `mapstructure:"stream_size" json:"stream_size" envconfig:"stream_size" yaml:"stream_size" toml:"stream_size" doc:"Maximum number of updates kept in the per-channel change stream used to bring subscribers up to date."`
|
||||
StreamTTL Duration `mapstructure:"stream_ttl" json:"stream_ttl" envconfig:"stream_ttl" yaml:"stream_ttl" toml:"stream_ttl" doc:"How long the per-channel change stream is retained, e.g. <<60s>>."`
|
||||
MetaTTL Duration `mapstructure:"meta_ttl" json:"meta_ttl" envconfig:"meta_ttl" yaml:"meta_ttl" toml:"meta_ttl" doc:"How long map stream meta information is retained. Should be larger than stream_ttl."`
|
||||
RemoveClientOnUnsubscribe bool `mapstructure:"remove_client_on_unsubscribe" json:"remove_client_on_unsubscribe" envconfig:"remove_client_on_unsubscribe" yaml:"remove_client_on_unsubscribe" toml:"remove_client_on_unsubscribe" doc:"Removes the client's own key from the map automatically when it unsubscribes."`
|
||||
ClientKey string `mapstructure:"client_key" json:"client_key" envconfig:"client_key" yaml:"client_key" toml:"client_key" doc:"Template for the key a client writes into the map, e.g. based on client or user ID."`
|
||||
AllowPublishForClient bool `mapstructure:"allow_publish_for_client" json:"allow_publish_for_client" envconfig:"allow_publish_for_client" yaml:"allow_publish_for_client" toml:"allow_publish_for_client" doc:"Allows authenticated clients to write keys into the map. Client-side writes bypass your backend — use with care."`
|
||||
AllowPublishForSubscriber bool `mapstructure:"allow_publish_for_subscriber" json:"allow_publish_for_subscriber" envconfig:"allow_publish_for_subscriber" yaml:"allow_publish_for_subscriber" toml:"allow_publish_for_subscriber" doc:"Allows subscribed clients to write keys into the map. Client-side writes bypass your backend — use with care."`
|
||||
AllowPublishForAnonymous bool `mapstructure:"allow_publish_for_anonymous" json:"allow_publish_for_anonymous" envconfig:"allow_publish_for_anonymous" yaml:"allow_publish_for_anonymous" toml:"allow_publish_for_anonymous" doc:"Allows anonymous clients to write keys into the map. Client-side writes bypass your backend — use with care."`
|
||||
AllowRemoveForClient bool `mapstructure:"allow_remove_for_client" json:"allow_remove_for_client" envconfig:"allow_remove_for_client" yaml:"allow_remove_for_client" toml:"allow_remove_for_client" doc:"Allows authenticated clients to remove keys from the map. Use with care."`
|
||||
AllowRemoveForSubscriber bool `mapstructure:"allow_remove_for_subscriber" json:"allow_remove_for_subscriber" envconfig:"allow_remove_for_subscriber" yaml:"allow_remove_for_subscriber" toml:"allow_remove_for_subscriber" doc:"Allows subscribed clients to remove keys from the map. Use with care."`
|
||||
AllowRemoveForAnonymous bool `mapstructure:"allow_remove_for_anonymous" json:"allow_remove_for_anonymous" envconfig:"allow_remove_for_anonymous" yaml:"allow_remove_for_anonymous" toml:"allow_remove_for_anonymous" doc:"Allows anonymous clients to remove keys from the map. Use with care."`
|
||||
PublishProxyEnabled bool `mapstructure:"publish_proxy_enabled" json:"publish_proxy_enabled" envconfig:"publish_proxy_enabled" yaml:"publish_proxy_enabled" toml:"publish_proxy_enabled" doc:"Proxies map key writes to your backend for authorization or transformation. Requires a configured publish proxy."`
|
||||
PublishProxyName string `mapstructure:"publish_proxy_name" default:"default" json:"publish_proxy_name" envconfig:"publish_proxy_name" yaml:"publish_proxy_name" toml:"publish_proxy_name" expose:"full" doc:"Name of the configured proxy to use for map key writes. Defaults to <<default>>."`
|
||||
RemoveProxyEnabled bool `mapstructure:"remove_proxy_enabled" json:"remove_proxy_enabled" envconfig:"remove_proxy_enabled" yaml:"remove_proxy_enabled" toml:"remove_proxy_enabled" doc:"Proxies map key removals to your backend for authorization. Requires a configured remove proxy."`
|
||||
RemoveProxyName string `mapstructure:"remove_proxy_name" default:"default" json:"remove_proxy_name" envconfig:"remove_proxy_name" yaml:"remove_proxy_name" toml:"remove_proxy_name" expose:"full" doc:"Name of the configured proxy to use for map key removals. Defaults to <<default>>."`
|
||||
DefaultPageSize int `mapstructure:"default_page_size" json:"default_page_size" envconfig:"default_page_size" yaml:"default_page_size" toml:"default_page_size" doc:"Default number of keys returned per page when a client does not specify a page size."`
|
||||
MinPageSize int `mapstructure:"min_page_size" json:"min_page_size" envconfig:"min_page_size" yaml:"min_page_size" toml:"min_page_size" doc:"Minimum page size a client may request when paginating map keys."`
|
||||
MaxPageSize int `mapstructure:"max_page_size" json:"max_page_size" envconfig:"max_page_size" yaml:"max_page_size" toml:"max_page_size" doc:"Maximum page size a client may request when paginating map keys."`
|
||||
LiveTransitionMaxPublicationLimit int `mapstructure:"live_transition_max_publication_limit" json:"live_transition_max_publication_limit" envconfig:"live_transition_max_publication_limit" yaml:"live_transition_max_publication_limit" toml:"live_transition_max_publication_limit" doc:"Maximum number of buffered publications allowed when a subscriber transitions to the live state. Exceeding it falls back to a full state load."`
|
||||
SubscribeCatchUpTimeout Duration `mapstructure:"subscribe_catch_up_timeout" json:"subscribe_catch_up_timeout" envconfig:"subscribe_catch_up_timeout" yaml:"subscribe_catch_up_timeout" toml:"subscribe_catch_up_timeout" doc:"Maximum time a subscriber may spend catching up to the live map state before the attempt is aborted, e.g. <<10s>>."`
|
||||
}
|
||||
|
||||
// SharedPollConfig contains configuration for shared poll subscription type.
|
||||
type SharedPollConfig struct {
|
||||
ProxyName string `mapstructure:"proxy_name" json:"proxy_name" envconfig:"proxy_name" yaml:"proxy_name" toml:"proxy_name" expose:"full" doc:"Name of the configured proxy used to poll state for shared-poll channels in this namespace."`
|
||||
RefreshInterval Duration `mapstructure:"refresh_interval" json:"refresh_interval" envconfig:"refresh_interval" yaml:"refresh_interval" toml:"refresh_interval" doc:"How often Centrifugo polls the backend for fresh state, e.g. <<5s>>. Lower values reduce latency but increase backend load."`
|
||||
RefreshBatchSize int `mapstructure:"refresh_batch_size" json:"refresh_batch_size" envconfig:"refresh_batch_size" yaml:"refresh_batch_size" toml:"refresh_batch_size" doc:"Number of keys polled from the backend per refresh request."`
|
||||
MaxKeysPerConnection int `mapstructure:"max_keys_per_connection" json:"max_keys_per_connection" envconfig:"max_keys_per_connection" yaml:"max_keys_per_connection" toml:"max_keys_per_connection" doc:"Maximum number of shared-poll keys a single connection may subscribe to."`
|
||||
Mode string `mapstructure:"mode" json:"mode" envconfig:"mode" yaml:"mode" toml:"mode" expose:"full" doc:"Shared poll mode controlling how state is fetched and delivered. See shared poll docs for supported values."`
|
||||
ChannelShutdownDelay Duration `mapstructure:"channel_shutdown_delay" json:"channel_shutdown_delay" envconfig:"channel_shutdown_delay" yaml:"channel_shutdown_delay" toml:"channel_shutdown_delay" doc:"Delay before a channel with no subscribers stops polling and shuts down, e.g. <<10s>>. Avoids churn from quick resubscribes."`
|
||||
TrackExpiredExtraDelay Duration `mapstructure:"track_expired_extra_delay" json:"track_expired_extra_delay" envconfig:"track_expired_extra_delay" yaml:"track_expired_extra_delay" toml:"track_expired_extra_delay" doc:"Extra time an expired key keeps being tracked before removal, e.g. <<5s>>."`
|
||||
PublishEnabled bool `mapstructure:"publish_enabled" json:"publish_enabled" envconfig:"publish_enabled" yaml:"publish_enabled" toml:"publish_enabled" doc:"Allows publishing into shared-poll channels in addition to backend polling."`
|
||||
}
|
||||
|
||||
type Compiled struct {
|
||||
CompiledChannelRegex *regexp.Regexp `json:"-" yaml:"-" toml:"-" envconfig:"-"`
|
||||
}
|
||||
116
internal/configtypes/pem.go
Normal file
116
internal/configtypes/pem.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package configtypes
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
// PEMData represents a flexible PEM-encoded source.
|
||||
// The order sources checked is the following:
|
||||
// 1. Raw PEM content
|
||||
// 2. Base64 encoded PEM content
|
||||
// 3. Path to file with PEM content
|
||||
type PEMData string
|
||||
|
||||
// String converts PEMData to a string.
|
||||
func (p PEMData) String() string {
|
||||
return string(p)
|
||||
}
|
||||
|
||||
// MarshalJSON converts PEMData to JSON.
|
||||
func (p PEMData) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(string(p))
|
||||
}
|
||||
|
||||
// UnmarshalJSON parses PEMData from JSON.
|
||||
func (p *PEMData) UnmarshalJSON(data []byte) error {
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
*p = PEMData(str)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalText converts PEMData to text for TOML.
|
||||
func (p PEMData) MarshalText() ([]byte, error) {
|
||||
return []byte(p.String()), nil
|
||||
}
|
||||
|
||||
// UnmarshalText parses PEMData from text (used in TOML).
|
||||
func (p *PEMData) UnmarshalText(text []byte) error {
|
||||
*p = PEMData(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalYAML converts PEMData to a YAML-compatible format.
|
||||
func (p PEMData) MarshalYAML() (interface{}, error) {
|
||||
return p.String(), nil
|
||||
}
|
||||
|
||||
// UnmarshalYAML parses PEMData from YAML.
|
||||
func (p *PEMData) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||
var str string
|
||||
if err := unmarshal(&str); err != nil {
|
||||
return err
|
||||
}
|
||||
*p = PEMData(str)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StringToPEMDataHookFunc for mapstructure to decode PEMData from strings.
|
||||
func StringToPEMDataHookFunc() mapstructure.DecodeHookFunc {
|
||||
return func(
|
||||
f reflect.Type,
|
||||
t reflect.Type,
|
||||
data interface{},
|
||||
) (interface{}, error) {
|
||||
if f.Kind() != reflect.String {
|
||||
return data, nil
|
||||
}
|
||||
if t != reflect.TypeOf(PEMData("")) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
return PEMData(data.(string)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// isValidPEM validates if the input string is a valid PEM block.
|
||||
func isValidPEM(pemData string) bool {
|
||||
// Decode the PEM data
|
||||
block, _ := pem.Decode([]byte(pemData))
|
||||
return block != nil
|
||||
}
|
||||
|
||||
// Load detects if PEMData is a file path, base64 string, or raw PEM string and loads the content.
|
||||
func (p PEMData) Load(statFile StatFileFunc, readFile ReadFileFunc) ([]byte, string, error) {
|
||||
value := string(p)
|
||||
if isValidPEM(value) {
|
||||
return []byte(value), "raw pem", nil
|
||||
}
|
||||
// Check if it's base64 encoded.
|
||||
if decodedValue, err := base64.StdEncoding.DecodeString(value); err == nil {
|
||||
if isValidPEM(string(decodedValue)) {
|
||||
return decodedValue, "base64 pem", nil
|
||||
}
|
||||
}
|
||||
// Check if it's a file path by verifying if the file exists.
|
||||
if _, err := statFile(value); err == nil {
|
||||
content, err := readFile(value)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("error reading file: %w", err)
|
||||
}
|
||||
if !isValidPEM(string(content)) {
|
||||
return nil, "", fmt.Errorf("file \"%s\" contains invalid PEM data", value)
|
||||
}
|
||||
return content, "pem file path", nil
|
||||
}
|
||||
return nil, "", errors.New("invalid PEM data: not a valid file path, base64-encoded PEM content, or raw PEM content")
|
||||
}
|
||||
108
internal/configtypes/pem_test.go
Normal file
108
internal/configtypes/pem_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package configtypes
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPEMData_Load(t *testing.T) {
|
||||
mockStatFile := func(name string) (os.FileInfo, error) {
|
||||
if name != "test-file" {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
mockReadFile := func(name string) ([]byte, error) {
|
||||
if name != "test-file" {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return []byte(testPEMCert), nil
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data PEMData
|
||||
expected string
|
||||
}{
|
||||
{"File Path", PEMData("test-file"), testPEMCert},
|
||||
{"Base64", PEMData(base64.StdEncoding.EncodeToString([]byte(testPEMCert))), testPEMCert},
|
||||
{"Raw PEM", PEMData(testPEMCert), testPEMCert},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data, _, err := tt.data.Load(mockStatFile, mockReadFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expected, string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringToPEMDataHookFunc(t *testing.T) {
|
||||
hook := StringToPEMDataHookFunc().(func(
|
||||
f reflect.Type,
|
||||
t reflect.Type,
|
||||
data interface{},
|
||||
) (interface{}, error))
|
||||
result, err := hook(reflect.TypeOf(""), reflect.TypeOf(PEMData("")), "test-data")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PEMData("test-data"), result)
|
||||
}
|
||||
|
||||
const testPEMCert = `
|
||||
-- GlobalSign Root R2, valid until Dec 15, 2021
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G
|
||||
A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp
|
||||
Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1
|
||||
MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG
|
||||
A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
|
||||
hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL
|
||||
v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8
|
||||
eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq
|
||||
tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd
|
||||
C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa
|
||||
zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB
|
||||
mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH
|
||||
V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n
|
||||
bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG
|
||||
3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs
|
||||
J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO
|
||||
291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS
|
||||
ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd
|
||||
AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7
|
||||
TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg==
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
func Test_isValidPEM(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
expected bool
|
||||
}{
|
||||
{"Valid PEM", testPEMCert, true},
|
||||
{"Invalid PEM", "/etc/passwd", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isValidPEM(tt.data)
|
||||
require.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const invalidPEM = "invalid PEM content"
|
||||
|
||||
func TestPEMData_Load_Invalid(t *testing.T) {
|
||||
pem := PEMData(invalidPEM)
|
||||
|
||||
_, _, err := pem.Load(mockStatFileSuccess, mockReadFileInvalid)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid PEM data")
|
||||
}
|
||||
64
internal/configtypes/redis.go
Normal file
64
internal/configtypes/redis.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package configtypes
|
||||
|
||||
type Redis struct {
|
||||
// Address is a list of Redis shard addresses. In most cases a single shard is used. But when many
|
||||
// addresses provided Centrifugo will distribute keys between shards using consistent hashing.
|
||||
Address []string `mapstructure:"address" json:"address" envconfig:"address" default:"redis://127.0.0.1:6379" yaml:"address" toml:"address" expose:"url" doc:"List of Redis shard addresses, e.g. <<redis://127.0.0.1:6379>>. Provide several to shard keys across shards using consistent hashing."`
|
||||
// ConnectTimeout is a timeout for establishing connection to Redis.
|
||||
ConnectTimeout Duration `mapstructure:"connect_timeout" json:"connect_timeout" envconfig:"connect_timeout" default:"1s" yaml:"connect_timeout" toml:"connect_timeout" doc:"Timeout for establishing a connection to Redis. Default <<1s>>."`
|
||||
// IOTimeout is a timeout for all read/write operations against Redis (can be considered as a request timeout).
|
||||
IOTimeout Duration `mapstructure:"io_timeout" json:"io_timeout" envconfig:"io_timeout" default:"4s" yaml:"io_timeout" toml:"io_timeout" doc:"Timeout for read/write operations against Redis (effectively a request timeout). Default <<4s>>."`
|
||||
// DB is a Redis database to use. Generally it's not recommended to use non-zero DB. Note, that Redis
|
||||
// PUB/SUB is global for all databases in a single Redis instance. So when using non-zero DB make sure
|
||||
// that different Centrifugo setups use different prefixes.
|
||||
DB int `mapstructure:"db" json:"db" envconfig:"db" default:"0" yaml:"db" toml:"db" doc:"Redis database number to use. Using a non-zero DB is generally not recommended since Redis PUB/SUB is shared across all databases; if you do, make sure different Centrifugo setups use different prefixes."`
|
||||
// User is a Redis user.
|
||||
User string `mapstructure:"user" json:"user" envconfig:"user" yaml:"user" toml:"user" expose:"full" doc:"Redis user for authentication (Redis ACL)."`
|
||||
// Password is a Redis password.
|
||||
Password string `mapstructure:"password" json:"password" envconfig:"password" yaml:"password" toml:"password" doc:"Redis password for authentication."`
|
||||
// ClientName allows changing a Redis client name used when connecting.
|
||||
ClientName string `mapstructure:"client_name" json:"client_name" envconfig:"client_name" yaml:"client_name" toml:"client_name" expose:"full" doc:"Client name Centrifugo presents when connecting to Redis."`
|
||||
// ForceResp2 forces use of Redis Resp2 protocol for communication.
|
||||
ForceResp2 bool `mapstructure:"force_resp2" json:"force_resp2" envconfig:"force_resp2" yaml:"force_resp2" toml:"force_resp2" doc:"Forces use of the Redis RESP2 protocol instead of RESP3."`
|
||||
// ClusterAddress is a list of Redis cluster addresses. When several provided - data will be sharded
|
||||
// between them using consistent hashing. Several Cluster addresses within one shard may be passed
|
||||
// comma-separated.
|
||||
ClusterAddress []string `mapstructure:"cluster_address" json:"cluster_address" envconfig:"cluster_address" yaml:"cluster_address" toml:"cluster_address" expose:"url" doc:"List of Redis Cluster addresses to enable cluster mode. Provide several to shard across clusters using consistent hashing; addresses within one shard may be comma-separated."`
|
||||
// TLS is a configuration for Redis TLS support.
|
||||
TLS TLSConfig `mapstructure:"tls" json:"tls" envconfig:"tls" yaml:"tls" toml:"tls" doc:"TLS configuration for connections to Redis."`
|
||||
// SentinelAddress allows setting Redis Sentinel addresses. When provided - Sentinel will be used.
|
||||
// When multiple addresses provided - data will be sharded between them using consistent hashing.
|
||||
// Several Sentinel addresses within one shard may be passed comma-separated.
|
||||
SentinelAddress []string `mapstructure:"sentinel_address" json:"sentinel_address" envconfig:"sentinel_address" yaml:"sentinel_address" toml:"sentinel_address" expose:"url" doc:"List of Redis Sentinel addresses to enable Sentinel mode. Provide several to shard across them using consistent hashing; addresses within one shard may be comma-separated."`
|
||||
// SentinelUser is a Redis Sentinel user.
|
||||
SentinelUser string `mapstructure:"sentinel_user" json:"sentinel_user" envconfig:"sentinel_user" yaml:"sentinel_user" toml:"sentinel_user" expose:"full" doc:"User for authenticating with Redis Sentinel."`
|
||||
// SentinelPassword is a Redis Sentinel password.
|
||||
SentinelPassword string `mapstructure:"sentinel_password" json:"sentinel_password" envconfig:"sentinel_password" yaml:"sentinel_password" toml:"sentinel_password" doc:"Password for authenticating with Redis Sentinel."`
|
||||
// SentinelMasterName is a Redis master name in Sentinel setup.
|
||||
SentinelMasterName string `mapstructure:"sentinel_master_name" json:"sentinel_master_name" envconfig:"sentinel_master_name" yaml:"sentinel_master_name" toml:"sentinel_master_name" expose:"full" doc:"Name of the Redis master to discover via Sentinel. Required when using Sentinel."`
|
||||
// SentinelClientName is a Redis Sentinel client name used when connecting.
|
||||
SentinelClientName string `mapstructure:"sentinel_client_name" json:"sentinel_client_name" envconfig:"sentinel_client_name" yaml:"sentinel_client_name" toml:"sentinel_client_name" expose:"full" doc:"Client name Centrifugo presents when connecting to Redis Sentinel."`
|
||||
// SentinelTLS is a configuration for Redis Sentinel TLS support.
|
||||
SentinelTLS TLSConfig `mapstructure:"sentinel_tls" json:"sentinel_tls" envconfig:"sentinel_tls" yaml:"sentinel_tls" toml:"sentinel_tls" doc:"TLS configuration for connections to Redis Sentinel."`
|
||||
// ReplicaClient is a configuration for Redis replica client.
|
||||
ReplicaClient RedisReplicaClient `mapstructure:"replica_client" json:"replica_client" envconfig:"replica_client" yaml:"replica_client" toml:"replica_client" doc:"Configuration of a Redis replica client to offload read operations to replicas."`
|
||||
}
|
||||
|
||||
type RedisPrefixed struct {
|
||||
// Prefix for all Redis keys and channels.
|
||||
Prefix string `mapstructure:"prefix" json:"prefix" envconfig:"prefix" default:"centrifugo" yaml:"prefix" toml:"prefix" expose:"full" doc:"Prefix for all Redis keys and channels used by Centrifugo. Default <<centrifugo>>."`
|
||||
Redis `mapstructure:",squash" yaml:",inline"`
|
||||
}
|
||||
|
||||
// RedisEmptyPrefixed is like RedisPrefixed but defaults to an empty prefix.
|
||||
// Useful for standalone pub/sub channels where a prefix is not needed by default.
|
||||
type RedisEmptyPrefixed struct {
|
||||
Prefix string `mapstructure:"prefix" json:"prefix" envconfig:"prefix" yaml:"prefix" toml:"prefix" expose:"full" doc:"Prefix for Redis keys and channels. Empty by default."`
|
||||
Redis `mapstructure:",squash" yaml:",inline"`
|
||||
}
|
||||
|
||||
// RedisReplicaClient allows configuring Redis replica options.
|
||||
type RedisReplicaClient struct {
|
||||
// Enabled enables replica client.
|
||||
Enabled bool `mapstructure:"enabled" json:"enabled" envconfig:"enabled" yaml:"enabled" toml:"enabled" doc:"Enables a separate Redis replica client so read operations can be served from replicas."`
|
||||
}
|
||||
25
internal/configtypes/rpc_namespace.go
Normal file
25
internal/configtypes/rpc_namespace.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package configtypes
|
||||
|
||||
type RPCNamespaces []RpcNamespace
|
||||
|
||||
// Decode to implement the envconfig.Decoder interface
|
||||
func (d *RPCNamespaces) Decode(value string) error {
|
||||
return decodeToNamedSlice(value, d)
|
||||
}
|
||||
|
||||
// RpcNamespace allows creating rules for different rpc.
|
||||
type RpcNamespace struct {
|
||||
// Name is a unique rpc namespace name.
|
||||
Name string `mapstructure:"name" json:"name" envconfig:"name" yaml:"name" toml:"name" expose:"full" doc:"Unique RPC namespace name. RPC methods prefixed with <<name:>> use this namespace's options."`
|
||||
|
||||
// Options for rpc namespace.
|
||||
RpcOptions `mapstructure:",squash" yaml:",inline"`
|
||||
}
|
||||
|
||||
// RpcOptions can set a custom behaviour for rpc namespace.
|
||||
type RpcOptions struct {
|
||||
// ProxyEnabled allows to enable using RPC proxy for this namespace.
|
||||
ProxyEnabled bool `mapstructure:"proxy_enabled" json:"proxy_enabled" envconfig:"proxy_enabled" yaml:"proxy_enabled" toml:"proxy_enabled" doc:"Proxy RPC calls in this namespace to your backend. Requires a configured RPC proxy."`
|
||||
// ProxyName which should be used for RPC namespace.
|
||||
ProxyName string `mapstructure:"proxy_name" default:"default" json:"proxy_name" envconfig:"proxy_name" yaml:"proxy_name" toml:"proxy_name" expose:"full" doc:"Name of the configured proxy to use for RPC calls in this namespace. Defaults to <<default>>."`
|
||||
}
|
||||
126
internal/configtypes/stringkeyvalue.go
Normal file
126
internal/configtypes/stringkeyvalue.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package configtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
type StringKeyValue struct {
|
||||
// Key is the key of the key/value pair. Must be unique within a StringKeyValues list.
|
||||
Key string `mapstructure:"key" json:"key" envconfig:"key" yaml:"key" toml:"key" expose:"full" doc:"Key of the key/value pair. Must be unique within the list."`
|
||||
// Value is the value of the key/value pair.
|
||||
Value string `mapstructure:"value" json:"value" envconfig:"value" yaml:"value" toml:"value" doc:"Value of the key/value pair. Supports <<\\${ENV_VAR}>> expansion from environment variables."`
|
||||
}
|
||||
|
||||
type StringKeyValues []StringKeyValue
|
||||
|
||||
// ToMap converts StringKeyValues to a map[string]string for easier lookups.
|
||||
func (s *StringKeyValues) ToMap() map[string]string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]string, len(*s))
|
||||
for _, kv := range *s {
|
||||
m[kv.Key] = kv.Value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *StringKeyValues) Decode(value string) error {
|
||||
// Try decoding as map[string]string first (simpler syntax).
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(value), &m); err == nil {
|
||||
// Convert map to slice, applying env var expansion
|
||||
if err := expandEnvVars(m); err != nil {
|
||||
return err
|
||||
}
|
||||
result := make([]StringKeyValue, 0, len(m))
|
||||
for k, v := range m {
|
||||
result = append(result, StringKeyValue{Key: k, Value: v})
|
||||
}
|
||||
*s = result
|
||||
return nil
|
||||
}
|
||||
|
||||
// If that fails, try decoding as slice of key/value objects.
|
||||
var kvList []StringKeyValue
|
||||
if err := json.Unmarshal([]byte(value), &kvList); err != nil {
|
||||
return fmt.Errorf("cannot decode StringKeyValues: %w", err)
|
||||
}
|
||||
|
||||
// Validate and apply env var expansion
|
||||
m2 := make(map[string]string)
|
||||
for i, kv := range kvList {
|
||||
if _, exists := m2[kv.Key]; exists {
|
||||
return fmt.Errorf("duplicate key %q at element %d", kv.Key, i)
|
||||
}
|
||||
m2[kv.Key] = kv.Value
|
||||
}
|
||||
|
||||
if err := expandEnvVars(m2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update values with expanded env vars
|
||||
for i := range kvList {
|
||||
kvList[i].Value = m2[kvList[i].Key]
|
||||
}
|
||||
|
||||
*s = kvList
|
||||
return nil
|
||||
}
|
||||
|
||||
func StringToStringKeyValuesHookFunc() mapstructure.DecodeHookFunc {
|
||||
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
|
||||
if t != reflect.TypeOf(StringKeyValues{}) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Only support slice of key/value objects.
|
||||
v, ok := data.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported type %T for StringKeyValues, expected slice of key/value objects", data)
|
||||
}
|
||||
|
||||
result := make([]StringKeyValue, 0, len(v))
|
||||
m := make(map[string]string)
|
||||
for i, item := range v {
|
||||
kvMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected map for element %d, got %T", i, item)
|
||||
}
|
||||
|
||||
keyI, ok := kvMap["key"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing or invalid key in element %d", i)
|
||||
}
|
||||
|
||||
if _, exists := m[keyI]; exists {
|
||||
return nil, fmt.Errorf("duplicate key %q at element %d", keyI, i)
|
||||
}
|
||||
|
||||
valI, ok := kvMap["value"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing or invalid value in element %d", i)
|
||||
}
|
||||
|
||||
m[keyI] = valI
|
||||
result = append(result, StringKeyValue{Key: keyI, Value: valI})
|
||||
}
|
||||
|
||||
// Apply env var expansion
|
||||
if err := expandEnvVars(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update values with expanded env vars
|
||||
for i := range result {
|
||||
result[i].Value = m[result[i].Key]
|
||||
}
|
||||
|
||||
return StringKeyValues(result), nil
|
||||
}
|
||||
}
|
||||
412
internal/configtypes/stringkeyvalue_test.go
Normal file
412
internal/configtypes/stringkeyvalue_test.go
Normal file
@@ -0,0 +1,412 @@
|
||||
package configtypes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStringKeyValues_ToMap(t *testing.T) {
|
||||
t.Run("basic conversion", func(t *testing.T) {
|
||||
kvs := StringKeyValues{
|
||||
{Key: "key1", Value: "value1"},
|
||||
{Key: "key2", Value: "value2"},
|
||||
{Key: "key3", Value: "value3"},
|
||||
}
|
||||
|
||||
m := kvs.ToMap()
|
||||
|
||||
expected := map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
}
|
||||
|
||||
require.Equal(t, expected, m)
|
||||
})
|
||||
|
||||
t.Run("nil receiver", func(t *testing.T) {
|
||||
var kvs *StringKeyValues
|
||||
m := kvs.ToMap()
|
||||
require.Nil(t, m)
|
||||
})
|
||||
|
||||
t.Run("empty slice", func(t *testing.T) {
|
||||
kvs := StringKeyValues{}
|
||||
m := kvs.ToMap()
|
||||
require.NotNil(t, m)
|
||||
require.Empty(t, m)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStringKeyValues_Decode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
envVars map[string]string
|
||||
expected map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic JSON map decoding",
|
||||
input: `{"key1": "value1", "key2": "value2"}`,
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{"key1": "value1", "key2": "value2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "basic key/value slice decoding",
|
||||
input: `[{"key": "key1", "value": "value1"}, {"key": "key2", "value": "value2"}]`,
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{"key1": "value1", "key2": "value2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "JSON map with environment variable expansion",
|
||||
input: `{"header1": "${CENTRIFUGO_VAR_TEST_VAR}", "header2": "${CENTRIFUGO_VAR_ANOTHER_VAR}"}`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_TEST_VAR": "test_value", "CENTRIFUGO_VAR_ANOTHER_VAR": "another_value"},
|
||||
expected: map[string]string{"header1": "test_value", "header2": "another_value"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "JSON map with keys containing dots",
|
||||
input: `{"key.with.dots": "value1", "another.key": "value2"}`,
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{"key.with.dots": "value1", "another.key": "value2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "JSON map with case-sensitive keys",
|
||||
input: `{"KeyOne": "value1", "keyone": "value2"}`,
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{"KeyOne": "value1", "keyone": "value2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty JSON map",
|
||||
input: `{}`,
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "JSON map with missing environment variable",
|
||||
input: `{"header1": "${CENTRIFUGO_VAR_MISSING}"}`,
|
||||
envVars: map[string]string{},
|
||||
expected: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "environment variable expansion",
|
||||
input: `[{"key": "header1", "value": "${CENTRIFUGO_VAR_TEST_VAR}"}, {"key": "header2", "value": "${CENTRIFUGO_VAR_ANOTHER_VAR}"}]`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_TEST_VAR": "test_value", "CENTRIFUGO_VAR_ANOTHER_VAR": "another_value"},
|
||||
expected: map[string]string{"header1": "test_value", "header2": "another_value"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "mixed static and environment variables",
|
||||
input: `[{"key": "header1", "value": "prefix-${CENTRIFUGO_VAR_TEST_VAR}-suffix"}, {"key": "header2", "value": "static_value"}]`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_TEST_VAR": "dynamic"},
|
||||
expected: map[string]string{"header1": "prefix-dynamic-suffix", "header2": "static_value"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing environment variable",
|
||||
input: `[{"key": "header1", "value": "${CENTRIFUGO_VAR_MISSING_VAR}"}, {"key": "header2", "value": "static"}]`,
|
||||
envVars: map[string]string{},
|
||||
expected: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "multiple environment variables in one value",
|
||||
input: `[{"key": "header1", "value": "${CENTRIFUGO_VAR_VAR1}-${CENTRIFUGO_VAR_VAR2}"}]`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_VAR1": "first", "CENTRIFUGO_VAR_VAR2": "second"},
|
||||
expected: map[string]string{"header1": "first-second"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing one of multiple environment variables",
|
||||
input: `[{"key": "header1", "value": "${CENTRIFUGO_VAR_VAR1}-${CENTRIFUGO_VAR_MISSING_VAR}"}]`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_VAR1": "first"},
|
||||
expected: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty key/value slice",
|
||||
input: `[]`,
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
input: `[{"key": "key1", "value": "value1"`,
|
||||
envVars: map[string]string{},
|
||||
expected: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "environment variable with special characters",
|
||||
input: `[{"key": "header1", "value": "${CENTRIFUGO_VAR_WITH_UNDERSCORES}"}, {"key": "header2", "value": "${CENTRIFUGO_VAR_123}"}]`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_WITH_UNDERSCORES": "underscore_value", "CENTRIFUGO_VAR_123": "numeric_suffix"},
|
||||
expected: map[string]string{"header1": "underscore_value", "header2": "numeric_suffix"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no environment variables in values",
|
||||
input: `[{"key": "header1", "value": "plain_value"}, {"key": "header2", "value": "another_plain"}]`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_UNUSED": "unused"},
|
||||
expected: map[string]string{"header1": "plain_value", "header2": "another_plain"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "non-CENTRIFUGO_VAR pattern ignored",
|
||||
input: `[{"key": "header1", "value": "${INVALID_VAR}"}, {"key": "header2", "value": "static"}]`,
|
||||
envVars: map[string]string{"INVALID_VAR": "value"},
|
||||
expected: map[string]string{"header1": "${INVALID_VAR}", "header2": "static"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "mixed CENTRIFUGO_VAR and other patterns",
|
||||
input: `[{"key": "header1", "value": "${CENTRIFUGO_VAR_VALID}-${INVALID_VAR}"}]`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_VALID": "valid", "INVALID_VAR": "invalid"},
|
||||
expected: map[string]string{"header1": "valid-${INVALID_VAR}"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "other prefix patterns left unchanged",
|
||||
input: `[{"key": "header1", "value": "${OTHER_PREFIX_VAR}"}]`,
|
||||
envVars: map[string]string{"OTHER_PREFIX_VAR": "value"},
|
||||
expected: map[string]string{"header1": "${OTHER_PREFIX_VAR}"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing CENTRIFUGO_VAR environment variable",
|
||||
input: `[{"key": "header1", "value": "${CENTRIFUGO_VAR_MISSING}"}]`,
|
||||
envVars: map[string]string{},
|
||||
expected: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "duplicate key",
|
||||
input: `[{"key": "key1", "value": "value1"}, {"key": "key1", "value": "value2"}]`,
|
||||
envVars: map[string]string{},
|
||||
expected: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty key",
|
||||
input: `[{"key": "", "value": "value1"}]`,
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{"": "value1"},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up environment variables
|
||||
originalEnvVars := make(map[string]string)
|
||||
for key, value := range tt.envVars {
|
||||
originalEnvVars[key] = os.Getenv(key)
|
||||
err := os.Setenv(key, value)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Clean up environment variables after test
|
||||
defer func() {
|
||||
for key := range tt.envVars {
|
||||
if originalValue, existed := originalEnvVars[key]; existed {
|
||||
err := os.Setenv(key, originalValue)
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
err := os.Unsetenv(key)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var kvs StringKeyValues
|
||||
err := kvs.Decode(tt.input)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("StringKeyValues.Decode() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
actualMap := kvs.ToMap()
|
||||
if len(actualMap) != len(tt.expected) {
|
||||
t.Errorf("StringKeyValues.Decode() result length = %v, expected length %v", len(actualMap), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
for key, expectedValue := range tt.expected {
|
||||
if actualValue, exists := actualMap[key]; !exists {
|
||||
t.Errorf("StringKeyValues.Decode() missing key %v", key)
|
||||
} else if actualValue != expectedValue {
|
||||
t.Errorf("StringKeyValues.Decode() key %v = %v, expected %v", key, actualValue, expectedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringToStringKeyValuesHookFunc_WrongTypes(t *testing.T) {
|
||||
hook := StringToStringKeyValuesHookFunc().(func(
|
||||
f reflect.Type,
|
||||
t reflect.Type,
|
||||
data interface{},
|
||||
) (interface{}, error))
|
||||
|
||||
// Test with wrong target type
|
||||
result, err := hook(
|
||||
reflect.TypeOf([]interface{}{}), // from []interface{}
|
||||
reflect.TypeOf(map[string]string{}), // to map[string]string (not StringKeyValues)
|
||||
[]interface{}{map[string]interface{}{"key": "key1", "value": "value1"}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []interface{}{map[string]interface{}{"key": "key1", "value": "value1"}}, result) // Should return data unchanged
|
||||
}
|
||||
|
||||
func TestStringToStringKeyValuesHookFunc(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input any
|
||||
envVars map[string]string
|
||||
expected map[string]string // Use map for comparison
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic key/value slice conversion",
|
||||
input: []any{map[string]any{"key": "Key1", "value": "value1"}, map[string]any{"key": "Key2", "value": "value2"}},
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{"Key1": "value1", "Key2": "value2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "environment variable expansion",
|
||||
input: []any{map[string]any{"key": "header1", "value": "${CENTRIFUGO_VAR_TEST_VAR}"}, map[string]any{"key": "header2", "value": "${CENTRIFUGO_VAR_ANOTHER_VAR}"}},
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_TEST_VAR": "test_value", "CENTRIFUGO_VAR_ANOTHER_VAR": "another_value"},
|
||||
expected: map[string]string{"header1": "test_value", "header2": "another_value"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "non-CENTRIFUGO_VAR pattern ignored",
|
||||
input: []any{map[string]any{"key": "header1", "value": "${INVALID_VAR}"}, map[string]any{"key": "header2", "value": "static"}},
|
||||
envVars: map[string]string{"INVALID_VAR": "value"},
|
||||
expected: map[string]string{"header1": "${INVALID_VAR}", "header2": "static"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing environment variable",
|
||||
input: []any{map[string]any{"key": "header1", "value": "${CENTRIFUGO_VAR_MISSING}"}},
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "duplicate key",
|
||||
input: []any{map[string]any{"key": "key1", "value": "value1"}, map[string]any{"key": "key1", "value": "value2"}},
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unsupported type (map instead of slice)",
|
||||
input: map[string]interface{}{"key1": "value1", "key2": "value2"},
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-map element in slice",
|
||||
input: []any{"not a map"},
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing key field",
|
||||
input: []any{map[string]any{"value": "value1"}},
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing value field",
|
||||
input: []any{map[string]any{"key": "key1"}},
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
hook := StringToStringKeyValuesHookFunc().(func(
|
||||
f reflect.Type,
|
||||
t reflect.Type,
|
||||
data interface{},
|
||||
) (interface{}, error))
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up environment variables
|
||||
originalEnvVars := make(map[string]string)
|
||||
for key, value := range tt.envVars {
|
||||
originalEnvVars[key] = os.Getenv(key)
|
||||
err := os.Setenv(key, value)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Clean up environment variables after test
|
||||
defer func() {
|
||||
for key := range tt.envVars {
|
||||
if originalValue, existed := originalEnvVars[key]; existed {
|
||||
err := os.Setenv(key, originalValue)
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
err := os.Unsetenv(key)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Test the decode hook function
|
||||
result, err := hook(
|
||||
reflect.TypeOf([]interface{}{}), // from []interface{}
|
||||
reflect.TypeOf(StringKeyValues{}), // to StringKeyValues
|
||||
tt.input,
|
||||
)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("StringToStringKeyValuesHookFunc() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
resultKvs, ok := result.(StringKeyValues)
|
||||
if !ok {
|
||||
t.Errorf("StringToStringKeyValuesHookFunc() result is not StringKeyValues, got %T", result)
|
||||
return
|
||||
}
|
||||
|
||||
resultMap := resultKvs.ToMap()
|
||||
if len(resultMap) != len(tt.expected) {
|
||||
t.Errorf("StringToStringKeyValuesHookFunc() result length = %v, expected length %v", len(resultMap), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
for key, expectedValue := range tt.expected {
|
||||
if actualValue, exists := resultMap[key]; !exists {
|
||||
t.Errorf("StringToStringKeyValuesHookFunc() missing key %v", key)
|
||||
} else if actualValue != expectedValue {
|
||||
t.Errorf("StringToStringKeyValuesHookFunc() key %v = %v, expected %v", key, actualValue, expectedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
117
internal/configtypes/stringmap.go
Normal file
117
internal/configtypes/stringmap.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package configtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
type MapStringString map[string]string
|
||||
|
||||
var customEnvVarRegex = regexp.MustCompile(`\$\{(CENTRIFUGO_VAR_[^}]+)}`)
|
||||
|
||||
func expandEnvVars(m map[string]string) error {
|
||||
for key, val := range m {
|
||||
// First check if all CENTRIFUGO_VAR_ environment variables exist.
|
||||
matches := customEnvVarRegex.FindAllStringSubmatch(val, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) > 1 {
|
||||
envVar := match[1]
|
||||
|
||||
// Check if the environment variable exists.
|
||||
if _, exists := os.LookupEnv(envVar); !exists {
|
||||
return fmt.Errorf("environment variable %q not found", envVar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If all variables exist, do the replacement.
|
||||
m[key] = customEnvVarRegex.ReplaceAllStringFunc(val, func(match string) string {
|
||||
submatches := customEnvVarRegex.FindStringSubmatch(match)
|
||||
if len(submatches) > 1 {
|
||||
return os.Getenv(submatches[1])
|
||||
}
|
||||
return match // Fallback, shouldn't happen.
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MapStringString) Decode(value string) error {
|
||||
var m map[string]string
|
||||
err := json.Unmarshal([]byte(value), &m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = expandEnvVars(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*s = m
|
||||
return nil
|
||||
}
|
||||
|
||||
func StringToMapStringStringHookFunc() mapstructure.DecodeHookFunc {
|
||||
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
|
||||
if t != reflect.TypeOf(MapStringString{}) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
switch v := data.(type) {
|
||||
|
||||
// Old behavior: map[string]any – it's case-insensitive, and does not support dot (key delimiter in Viper) in the key.
|
||||
case map[string]any:
|
||||
m := make(map[string]string)
|
||||
for key, value := range v {
|
||||
strValue, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected string value for key %q, got %T", key, value)
|
||||
}
|
||||
m[key] = strValue
|
||||
}
|
||||
if err := expandEnvVars(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return MapStringString(m), nil
|
||||
|
||||
// Slice of key/value objects is the recommended way to define maps now.
|
||||
case []any:
|
||||
m := make(map[string]string)
|
||||
for i, item := range v {
|
||||
kvMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected map for element %d, got %T", i, item)
|
||||
}
|
||||
|
||||
keyI, ok := kvMap["key"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing or invalid key in element %d", i)
|
||||
}
|
||||
|
||||
if _, exists := m[keyI]; exists {
|
||||
return nil, fmt.Errorf("duplicate key %q at element %d", keyI, i)
|
||||
}
|
||||
|
||||
valI, ok := kvMap["value"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing or invalid value in element %d", i)
|
||||
}
|
||||
|
||||
m[keyI] = valI
|
||||
}
|
||||
if err := expandEnvVars(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return MapStringString(m), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported type %T for MapStringString", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
310
internal/configtypes/stringmap_test.go
Normal file
310
internal/configtypes/stringmap_test.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package configtypes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMapStringString_Decode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
envVars map[string]string
|
||||
expected map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic JSON decoding",
|
||||
input: `{"key1": "value1", "key2": "value2"}`,
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{"key1": "value1", "key2": "value2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "environment variable expansion",
|
||||
input: `{"header1": "${CENTRIFUGO_VAR_TEST_VAR}", "header2": "${CENTRIFUGO_VAR_ANOTHER_VAR}"}`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_TEST_VAR": "test_value", "CENTRIFUGO_VAR_ANOTHER_VAR": "another_value"},
|
||||
expected: map[string]string{"header1": "test_value", "header2": "another_value"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "mixed static and environment variables",
|
||||
input: `{"header1": "prefix-${CENTRIFUGO_VAR_TEST_VAR}-suffix", "header2": "static_value"}`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_TEST_VAR": "dynamic"},
|
||||
expected: map[string]string{"header1": "prefix-dynamic-suffix", "header2": "static_value"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing environment variable",
|
||||
input: `{"header1": "${CENTRIFUGO_VAR_MISSING_VAR}", "header2": "static"}`,
|
||||
envVars: map[string]string{},
|
||||
expected: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "multiple environment variables in one value",
|
||||
input: `{"header1": "${CENTRIFUGO_VAR_VAR1}-${CENTRIFUGO_VAR_VAR2}"}`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_VAR1": "first", "CENTRIFUGO_VAR_VAR2": "second"},
|
||||
expected: map[string]string{"header1": "first-second"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing one of multiple environment variables",
|
||||
input: `{"header1": "${CENTRIFUGO_VAR_VAR1}-${CENTRIFUGO_VAR_MISSING_VAR}"}`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_VAR1": "first"},
|
||||
expected: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty JSON object",
|
||||
input: `{}`,
|
||||
envVars: map[string]string{},
|
||||
expected: map[string]string{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
input: `{"key1": "value1"`,
|
||||
envVars: map[string]string{},
|
||||
expected: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "environment variable with special characters",
|
||||
input: `{"header1": "${CENTRIFUGO_VAR_WITH_UNDERSCORES}", "header2": "${CENTRIFUGO_VAR_123}"}`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_WITH_UNDERSCORES": "underscore_value", "CENTRIFUGO_VAR_123": "numeric_suffix"},
|
||||
expected: map[string]string{"header1": "underscore_value", "header2": "numeric_suffix"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no environment variables in values",
|
||||
input: `{"header1": "plain_value", "header2": "another_plain"}`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_UNUSED": "unused"},
|
||||
expected: map[string]string{"header1": "plain_value", "header2": "another_plain"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "non-CENTRIFUGO_VAR pattern ignored",
|
||||
input: `{"header1": "${INVALID_VAR}", "header2": "static"}`,
|
||||
envVars: map[string]string{"INVALID_VAR": "value"},
|
||||
expected: map[string]string{"header1": "${INVALID_VAR}", "header2": "static"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "mixed CENTRIFUGO_VAR and other patterns",
|
||||
input: `{"header1": "${CENTRIFUGO_VAR_VALID}-${INVALID_VAR}"}`,
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_VALID": "valid", "INVALID_VAR": "invalid"},
|
||||
expected: map[string]string{"header1": "valid-${INVALID_VAR}"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "other prefix patterns left unchanged",
|
||||
input: `{"header1": "${OTHER_PREFIX_VAR}"}`,
|
||||
envVars: map[string]string{"OTHER_PREFIX_VAR": "value"},
|
||||
expected: map[string]string{"header1": "${OTHER_PREFIX_VAR}"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing CENTRIFUGO_VAR environment variable",
|
||||
input: `{"header1": "${CENTRIFUGO_VAR_MISSING}"}`,
|
||||
envVars: map[string]string{},
|
||||
expected: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up environment variables
|
||||
originalEnvVars := make(map[string]string)
|
||||
for key, value := range tt.envVars {
|
||||
originalEnvVars[key] = os.Getenv(key)
|
||||
err := os.Setenv(key, value)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Clean up environment variables after test
|
||||
defer func() {
|
||||
for key := range tt.envVars {
|
||||
if originalValue, existed := originalEnvVars[key]; existed {
|
||||
err := os.Setenv(key, originalValue)
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
err := os.Unsetenv(key)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var m MapStringString
|
||||
err := m.Decode(tt.input)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("MapStringString.Decode() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
if len(m) != len(tt.expected) {
|
||||
t.Errorf("MapStringString.Decode() result length = %v, expected length %v", len(m), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
for key, expectedValue := range tt.expected {
|
||||
if actualValue, exists := m[key]; !exists {
|
||||
t.Errorf("MapStringString.Decode() missing key %v", key)
|
||||
} else if actualValue != expectedValue {
|
||||
t.Errorf("MapStringString.Decode() key %v = %v, expected %v", key, actualValue, expectedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringToMapStringStringHookFunc_WrongTypes(t *testing.T) {
|
||||
hook := StringToMapStringStringHookFunc().(func(
|
||||
f reflect.Type,
|
||||
t reflect.Type,
|
||||
data interface{},
|
||||
) (interface{}, error))
|
||||
|
||||
// Test with wrong target type
|
||||
result, err := hook(
|
||||
reflect.TypeOf(map[string]interface{}{}), // from map[string]interface{}
|
||||
reflect.TypeOf(map[string]string{}), // to map[string]string (not MapStringString)
|
||||
map[string]interface{}{"key": "value"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]interface{}{"key": "value"}, result) // Should return data unchanged
|
||||
}
|
||||
|
||||
func TestStringToMapStringStringHookFunc(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input any
|
||||
envVars map[string]string
|
||||
expected MapStringString
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic map conversion",
|
||||
input: map[string]interface{}{"key1": "value1", "key2": "value2"},
|
||||
envVars: map[string]string{},
|
||||
expected: MapStringString{"key1": "value1", "key2": "value2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "basic key/value slice map conversion",
|
||||
input: []any{map[string]any{"key": "Key1", "value": "value1"}, map[string]any{"key": "Key2", "value": "value2"}},
|
||||
envVars: map[string]string{},
|
||||
expected: MapStringString{"Key1": "value1", "Key2": "value2"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "environment variable expansion",
|
||||
input: map[string]interface{}{"header1": "${CENTRIFUGO_VAR_TEST_VAR}", "header2": "${CENTRIFUGO_VAR_ANOTHER_VAR}"},
|
||||
envVars: map[string]string{"CENTRIFUGO_VAR_TEST_VAR": "test_value", "CENTRIFUGO_VAR_ANOTHER_VAR": "another_value"},
|
||||
expected: MapStringString{"header1": "test_value", "header2": "another_value"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "non-CENTRIFUGO_VAR pattern ignored",
|
||||
input: map[string]interface{}{"header1": "${INVALID_VAR}", "header2": "static"},
|
||||
envVars: map[string]string{"INVALID_VAR": "value"},
|
||||
expected: MapStringString{"header1": "${INVALID_VAR}", "header2": "static"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing environment variable",
|
||||
input: map[string]interface{}{"header1": "${CENTRIFUGO_VAR_MISSING}"},
|
||||
envVars: map[string]string{},
|
||||
expected: MapStringString{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-string value in map",
|
||||
input: map[string]interface{}{"key1": "value1", "key2": 123},
|
||||
envVars: map[string]string{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-unique value in map",
|
||||
input: []any{map[string]any{"key": "key1", "value": "value1"}, map[string]any{"key": "key1", "value": "value2"}},
|
||||
envVars: map[string]string{},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
hook := StringToMapStringStringHookFunc().(func(
|
||||
f reflect.Type,
|
||||
t reflect.Type,
|
||||
data interface{},
|
||||
) (interface{}, error))
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up environment variables
|
||||
originalEnvVars := make(map[string]string)
|
||||
for key, value := range tt.envVars {
|
||||
originalEnvVars[key] = os.Getenv(key)
|
||||
err := os.Setenv(key, value)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Clean up environment variables after test
|
||||
defer func() {
|
||||
for key := range tt.envVars {
|
||||
if originalValue, existed := originalEnvVars[key]; existed {
|
||||
err := os.Setenv(key, originalValue)
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
err := os.Unsetenv(key)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Test the decode hook function with map input
|
||||
result, err := hook(
|
||||
reflect.TypeOf(map[string]interface{}{}), // from map[string]interface{}
|
||||
reflect.TypeOf(MapStringString{}), // to MapStringString
|
||||
tt.input,
|
||||
)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("StringToMapStringStringHookFunc() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
if tt.name == "non-string value in map" {
|
||||
// Should return original data unchanged
|
||||
require.Equal(t, tt.input, result)
|
||||
} else {
|
||||
resultMap, ok := result.(MapStringString)
|
||||
if !ok {
|
||||
t.Errorf("StringToMapStringStringHookFunc() result is not MapStringString, got %T", result)
|
||||
return
|
||||
}
|
||||
|
||||
if len(resultMap) != len(tt.expected) {
|
||||
t.Errorf("StringToMapStringStringHookFunc() result length = %v, expected length %v", len(resultMap), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
for key, expectedValue := range tt.expected {
|
||||
if actualValue, exists := resultMap[key]; !exists {
|
||||
t.Errorf("StringToMapStringStringHookFunc() missing key %v", key)
|
||||
} else if actualValue != expectedValue {
|
||||
t.Errorf("StringToMapStringStringHookFunc() key %v = %v, expected %v", key, actualValue, expectedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
184
internal/configtypes/tls.go
Normal file
184
internal/configtypes/tls.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package configtypes
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// TLSConfig is a common configuration for TLS.
|
||||
type TLSConfig struct {
|
||||
// Enabled turns on using TLS.
|
||||
Enabled bool `mapstructure:"enabled" json:"enabled" yaml:"enabled" toml:"enabled" envconfig:"enabled" doc:"Enables TLS for this entity."`
|
||||
// CertPem is a PEM certificate.
|
||||
CertPem PEMData `mapstructure:"cert_pem" json:"cert_pem" envconfig:"cert_pem" yaml:"cert_pem" toml:"cert_pem" expose:"full" doc:"Certificate in PEM format. May be provided as a file path, raw PEM string, or base64-encoded PEM."`
|
||||
// KeyPem is a path to a file with key in PEM format.
|
||||
KeyPem PEMData `mapstructure:"key_pem" json:"key_pem" envconfig:"key_pem" yaml:"key_pem" toml:"key_pem" doc:"Private key in PEM format matching the certificate. May be provided as a file path, raw PEM string, or base64-encoded PEM."`
|
||||
// ServerCAPem is a server root CA certificate in PEM format.
|
||||
// The client uses this certificate to verify the server's certificate during the TLS handshake.
|
||||
ServerCAPem PEMData `mapstructure:"server_ca_pem" json:"server_ca_pem" envconfig:"server_ca_pem" yaml:"server_ca_pem" toml:"server_ca_pem" expose:"full" doc:"Root CA certificate in PEM format used to verify the server's certificate during the handshake (client side). May be a file path, raw PEM, or base64-encoded PEM."`
|
||||
// ClientCAPem is a client CA certificate in PEM format.
|
||||
// The server uses this certificate to verify the client's certificate during the TLS handshake.
|
||||
ClientCAPem PEMData `mapstructure:"client_ca_pem" json:"client_ca_pem" envconfig:"client_ca_pem" yaml:"client_ca_pem" toml:"client_ca_pem" expose:"full" doc:"CA certificate in PEM format used to verify client certificates (server side). Setting it enables mutual TLS and requires clients to present a valid certificate. May be a file path, raw PEM, or base64-encoded PEM."`
|
||||
// InsecureSkipVerify turns off server certificate verification.
|
||||
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify" json:"insecure_skip_verify" envconfig:"insecure_skip_verify" yaml:"insecure_skip_verify" toml:"insecure_skip_verify" doc:"Disables verification of the server's certificate chain and host name. Insecure - use only for testing."`
|
||||
// ServerName is used to verify the hostname on the returned certificates.
|
||||
ServerName string `mapstructure:"server_name" json:"server_name" envconfig:"server_name" yaml:"server_name" toml:"server_name" expose:"full" doc:"Expected server name (SNI) used to verify the hostname on the returned certificate."`
|
||||
}
|
||||
|
||||
func (c TLSConfig) ToGoTLSConfig(logTraceEntity string) (*tls.Config, error) {
|
||||
if !c.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
logger := log.With().Str("entity", logTraceEntity).Logger()
|
||||
logger.Debug().Msg("TLS enabled")
|
||||
tlsConfig, err := makeTLSConfig(c, logger, os.ReadFile, os.Stat)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error make TLS config (for %s): %w", logTraceEntity, err)
|
||||
}
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
// ReadFileFunc is like os.ReadFile but helps in testing.
|
||||
type ReadFileFunc func(name string) ([]byte, error)
|
||||
|
||||
// StatFileFunc is like os.Stat but helps in testing.
|
||||
type StatFileFunc func(name string) (os.FileInfo, error)
|
||||
|
||||
// makeTLSConfig constructs a tls.Config instance using the given configuration.
|
||||
func makeTLSConfig(cfg TLSConfig, logger zerolog.Logger, readFile ReadFileFunc, statFile StatFileFunc) (*tls.Config, error) {
|
||||
tlsConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
if err := loadCertificate(cfg, logger, tlsConfig, readFile, statFile); err != nil {
|
||||
return nil, fmt.Errorf("error load certificate: %w", err)
|
||||
}
|
||||
if err := loadServerCA(cfg, logger, tlsConfig, readFile, statFile); err != nil {
|
||||
return nil, fmt.Errorf("error load server CA: %w", err)
|
||||
}
|
||||
if err := loadClientCA(cfg, logger, tlsConfig, readFile, statFile); err != nil {
|
||||
return nil, fmt.Errorf("error load client CA: %w", err)
|
||||
}
|
||||
tlsConfig.ServerName = cfg.ServerName
|
||||
tlsConfig.InsecureSkipVerify = cfg.InsecureSkipVerify
|
||||
logger.Debug().Str("server_name", cfg.ServerName).Bool("insecure_skip_verify", cfg.InsecureSkipVerify).Msg("TLS config options set")
|
||||
logger.Debug().Msg("TLS config created")
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
// loadCertificate loads the TLS certificate from various sources.
|
||||
func loadCertificate(cfg TLSConfig, logger zerolog.Logger, tlsConfig *tls.Config, readFile ReadFileFunc, statFile StatFileFunc) error {
|
||||
var certPEMBlock, keyPEMBlock []byte
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case cfg.CertPem != "" && cfg.KeyPem != "":
|
||||
var pemSource string
|
||||
certPEMBlock, pemSource, err = cfg.CertPem.Load(statFile, readFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load TLS certificate: %w", err)
|
||||
}
|
||||
logger.Debug().Str("pem_source", pemSource).Msg("loaded PEM certificate")
|
||||
keyPEMBlock, pemSource, err = cfg.KeyPem.Load(statFile, readFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load TLS key: %w", err)
|
||||
}
|
||||
logger.Debug().Str("pem_source", pemSource).Msg("loaded PEM key")
|
||||
default:
|
||||
}
|
||||
|
||||
if len(certPEMBlock) > 0 && len(keyPEMBlock) > 0 {
|
||||
logger.Debug().Msg("create x509 key pair")
|
||||
cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error create x509 key pair: %w", err)
|
||||
}
|
||||
tlsConfig.Certificates = []tls.Certificate{cert}
|
||||
} else {
|
||||
logger.Debug().Msg("no cert or key provided, skip loading x509 key pair")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadServerCA loads the root CA from various sources.
|
||||
func loadServerCA(cfg TLSConfig, logger zerolog.Logger, tlsConfig *tls.Config, readFile ReadFileFunc, statFile StatFileFunc) error {
|
||||
if cfg.ServerCAPem == "" {
|
||||
logger.Debug().Msg("no server CA certificate provided")
|
||||
return nil
|
||||
}
|
||||
caCert, err := loadPEMData(cfg.ServerCAPem, logger, "server CA", readFile, statFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error load server CA certificate: %w", err)
|
||||
}
|
||||
if len(caCert) > 0 {
|
||||
logger.Debug().Msg("load server CA certificate")
|
||||
caCertPool, err := newCertPoolFromPEM(caCert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error create server CA certificate pool: %w", err)
|
||||
}
|
||||
tlsConfig.RootCAs = caCertPool
|
||||
} else {
|
||||
logger.Debug().Msg("no server CA certificate provided")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadClientCA loads the client CA from various sources.
|
||||
func loadClientCA(cfg TLSConfig, logger zerolog.Logger, tlsConfig *tls.Config, readFile ReadFileFunc, statFile StatFileFunc) error {
|
||||
if cfg.ClientCAPem == "" {
|
||||
logger.Debug().Msg("no client CA certificate provided")
|
||||
return nil
|
||||
}
|
||||
caCert, err := loadPEMData(cfg.ClientCAPem, logger, "client CA", readFile, statFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(caCert) > 0 {
|
||||
logger.Debug().Msg("load client CA certificate")
|
||||
caCertPool, err := newCertPoolFromPEM(caCert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error create client CA certificate pool: %w", err)
|
||||
}
|
||||
tlsConfig.ClientCAs = caCertPool
|
||||
tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
} else {
|
||||
logger.Debug().Msg("no client CA certificate provided")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadPEMData attempts to load PEM data from a file, base64 string, or raw string.
|
||||
func loadPEMData(pemData PEMData, logger zerolog.Logger, certType string, readFile ReadFileFunc, statFile StatFileFunc) ([]byte, error) {
|
||||
pemBytes, pemSource, err := pemData.Load(statFile, readFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load PEM block for %s: %w", certType, err)
|
||||
}
|
||||
logger.Debug().Str("pem_source", pemSource).Msg("loaded PEM data")
|
||||
return pemBytes, nil
|
||||
}
|
||||
|
||||
// newCertPoolFromPEM returns certificate pool for the given PEM-encoded
|
||||
// certificate bundle. Note that it currently ignores invalid blocks.
|
||||
func newCertPoolFromPEM(pem []byte) (*x509.CertPool, error) {
|
||||
certPool := x509.NewCertPool()
|
||||
ok := certPool.AppendCertsFromPEM(pem)
|
||||
if !ok {
|
||||
return nil, errors.New("no valid certificates found")
|
||||
}
|
||||
return certPool, nil
|
||||
}
|
||||
|
||||
type TLSAutocert struct {
|
||||
Enabled bool `mapstructure:"enabled" json:"enabled" envconfig:"enabled" yaml:"enabled" toml:"enabled" doc:"Enables automatic TLS certificate provisioning via ACME (Let's Encrypt)."`
|
||||
HostWhitelist []string `mapstructure:"host_whitelist" json:"host_whitelist" envconfig:"host_whitelist" yaml:"host_whitelist" toml:"host_whitelist" expose:"full" doc:"List of host names ACME certificates are allowed to be issued for. Leave empty to allow any host (not recommended in production)."`
|
||||
CacheDir string `mapstructure:"cache_dir" json:"cache_dir" envconfig:"cache_dir" yaml:"cache_dir" toml:"cache_dir" expose:"full" doc:"Directory to cache issued certificates so they survive restarts and are shared between nodes."`
|
||||
Email string `mapstructure:"email" json:"email" envconfig:"email" yaml:"email" toml:"email" expose:"full" doc:"Contact email sent to the ACME provider for expiry notices and account registration."`
|
||||
ServerName string `mapstructure:"server_name" json:"server_name" envconfig:"server_name" yaml:"server_name" toml:"server_name" expose:"full" doc:"Server name (SNI) for which the certificate is requested."`
|
||||
HTTP bool `mapstructure:"http" json:"http" envconfig:"http" yaml:"http" toml:"http" doc:"Enables an HTTP server to handle ACME http-01 challenges instead of the default tls-alpn-01."`
|
||||
HTTPAddr string `mapstructure:"http_addr" json:"http_addr" envconfig:"http_addr" default:":80" yaml:"http_addr" toml:"http_addr" expose:"full" doc:"Address to bind the ACME http-01 challenge server to. Default <<:80>>."`
|
||||
}
|
||||
179
internal/configtypes/tls_test.go
Normal file
179
internal/configtypes/tls_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package configtypes
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Mock file-related functions
|
||||
func mockReadFileSuccess(name string) ([]byte, error) {
|
||||
return []byte(`-----BEGIN CERTIFICATE-----
|
||||
MIIDXTCCAkWgAwIBAgIJAO7rZ8LQQR6cMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
|
||||
...
|
||||
-----END CERTIFICATE-----`), nil
|
||||
}
|
||||
|
||||
func mockReadFileInvalid(name string) ([]byte, error) {
|
||||
return []byte("invalid content"), nil
|
||||
}
|
||||
|
||||
func mockStatFileSuccess(name string) (os.FileInfo, error) {
|
||||
return nil, nil // Mock successful stat
|
||||
}
|
||||
|
||||
// Sample valid PEM strings
|
||||
const validPEM = `-----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-----`
|
||||
|
||||
const validKey = `-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDLCNVIle5klfRt
|
||||
zjHe9sEo8zU9pqXfK9fxc2PZqfd6HVDVWyrOHNv9zWV8awEEgwX2kg+sY4chuKmN
|
||||
dD19UWxLovCMkA92gKhzJoPPBMlVRtSA9QWNw4cXXB25KErPPyBXyyFA13X/6N40
|
||||
8I26Aj6ewA0WLISkNgiCddUo31FygTNH4yWXF+F+lol0EJhG+K3E8diYub4P1Ul4
|
||||
17sQ/1FxcoGo43fGl8j4y6wCnBQkSNaQCr1vvNEzdmiIYF02a51Efdb3PrSu90nJ
|
||||
JBbFQxNhpcl98tLRF5t3wZJ+R2Xy4xPUZYwNNWTdICqW7a4bfD4foByp85kru44k
|
||||
w7laXghhAgMBAAECggEAdK4z3E4FvZqL6RrJgEgwg7cZTr/ZrWKF7EWTCYDrLytv
|
||||
y91jwSXGq5oBi7n20L/3ilcwWLKt8wwrrJYzzDQh12nhcfZMXJ7dr6dfsnYeujpF
|
||||
X4LwWSMYHK2ci08DhwzRKoMbLidksdgC80uXN2GY2SSnoKme5LwEsezDvoRwSyu7
|
||||
nQCsCqJ0hN41s04vlva8FmgNNOhavvIdR2FNYv3KhnAf/Wv1GfHzq/2kU8j46v6T
|
||||
/hcZ3m+LC2J982+dyyCp+9+1Grr7rGRHiKXABcpUubAgdnwKS74oyq998/NMEoz3
|
||||
OL0j1D6FxpCshLo3RP97nfnbJfguUL8eQ7uB1DR67QKBgQD7hpWGTV4urE8rLDb8
|
||||
YANthNMFtvB06ZGKK+R/HEewIt4dGqbwpBcp95Bvmjxa2oA26B8tWcKkIghWQmqx
|
||||
97icluXdo/x/FZvv5JZLkqZBkRzJFdCpAkbNyWmswo5YI9pzBd370fZqBN+KwhdB
|
||||
LPa3UML+RXPMD+EDRen3ryXniwKBgQDOpW0gZ26mhDO7/TRuzx5I7DIFfl4lIuMt
|
||||
1P9T7yY1fwnpgOvV7zl96PaqGMVSh5DvV0IR8PNVup0NAYk0qEMgyLdc+ZAu5r4N
|
||||
euQEeS9HzIThqoEBSq0u0ds73QirIQ+k8r39q1XABZfNvIauhAToQxdgQgPjQtiH
|
||||
ytygvR0tQwKBgB8cNF5aL24CbgBfBaYNkh73sMoiKHetdAztBOQb8Vn91g8vfrqA
|
||||
8USFlF3Za+Go6PbhmwmW8pYuh21z5ZKBm1ny6BeT8uUdHR583YIXb2zor/DHO/nL
|
||||
iEpnwSRXJBgOxzQ245AEFkBiveuBujKbhyCBYrzkhkAVLrWi7h9ukHelAoGBALOI
|
||||
UZj3g9Czxuaqg6VJ2LvuST8wnMaS2uD0zqezfHS53Hi8Ayko38AeaD87qiObmDX4
|
||||
j3Ra7G4s5UlpbjULgta2y2fBgpzc5316qSOhzYwJieEta0sd//xPYrNNw7w5ywe5
|
||||
xYrgEm3z7gFWq4RvOnw33dVJRWtqpgjEHI6h/vlVAoGAG0LyA8WuCp6wUcUUzy2U
|
||||
n0Ia8Dh5hCy8GvxslMgUy7t0efIkMV1dJ0IuefULUm5ItsGwqRMfLw05RE78ZoE1
|
||||
Jr18O4VLqXHDDYbamXO5koEljIeQ0Y8oAKJcB+f0w9bf70RpP/Bsi+fyHrOQHWBE
|
||||
uNfa+M0tCJMV+XiRsjqDNd8=
|
||||
-----END PRIVATE KEY-----`
|
||||
|
||||
// 🧠 Test for TLSConfig.ToGoTLSConfig
|
||||
func TestToGoTLSConfig_Disabled(t *testing.T) {
|
||||
cfg := TLSConfig{
|
||||
Enabled: false,
|
||||
}
|
||||
tlsCfg, err := cfg.ToGoTLSConfig("test-entity")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, tlsCfg)
|
||||
}
|
||||
|
||||
func TestToGoTLSConfig_Valid(t *testing.T) {
|
||||
logger := zerolog.Nop()
|
||||
|
||||
cfg := TLSConfig{
|
||||
Enabled: true,
|
||||
CertPem: PEMData(validPEM),
|
||||
KeyPem: PEMData(validKey),
|
||||
ServerCAPem: PEMData(validPEM),
|
||||
ClientCAPem: PEMData(validPEM),
|
||||
ServerName: "test.server",
|
||||
}
|
||||
|
||||
tlsCfg, err := makeTLSConfig(cfg, logger, mockReadFileSuccess, mockStatFileSuccess)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tlsCfg)
|
||||
require.Equal(t, "test.server", tlsCfg.ServerName)
|
||||
require.False(t, tlsCfg.InsecureSkipVerify)
|
||||
}
|
||||
|
||||
func TestMakeTLSConfig_InvalidCertKey(t *testing.T) {
|
||||
logger := zerolog.Nop()
|
||||
|
||||
cfg := TLSConfig{
|
||||
Enabled: true,
|
||||
CertPem: PEMData(invalidPEM),
|
||||
KeyPem: PEMData(invalidPEM),
|
||||
}
|
||||
|
||||
_, err := makeTLSConfig(cfg, logger, mockReadFileSuccess, mockStatFileSuccess)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "error load certificate")
|
||||
}
|
||||
|
||||
func TestMakeTLSConfig_InsecureSkipVerify(t *testing.T) {
|
||||
logger := zerolog.Nop()
|
||||
|
||||
cfg := TLSConfig{
|
||||
Enabled: true,
|
||||
CertPem: PEMData(validPEM),
|
||||
KeyPem: PEMData(validKey),
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
|
||||
tlsCfg, err := makeTLSConfig(cfg, logger, mockReadFileSuccess, mockStatFileSuccess)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tlsCfg)
|
||||
require.True(t, tlsCfg.InsecureSkipVerify)
|
||||
}
|
||||
|
||||
func TestLoadCertificate_NoPEM(t *testing.T) {
|
||||
logger := zerolog.Nop()
|
||||
tlsCfg := &tls.Config{}
|
||||
|
||||
err := loadCertificate(TLSConfig{}, logger, tlsCfg, mockReadFileSuccess, mockStatFileSuccess)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, tlsCfg.Certificates)
|
||||
}
|
||||
|
||||
func TestLoadServerCA_NoPEM(t *testing.T) {
|
||||
logger := zerolog.Nop()
|
||||
tlsCfg := &tls.Config{}
|
||||
|
||||
err := loadServerCA(TLSConfig{}, logger, tlsCfg, mockReadFileSuccess, mockStatFileSuccess)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, tlsCfg.RootCAs)
|
||||
}
|
||||
|
||||
func TestLoadClientCA_NoPEM(t *testing.T) {
|
||||
logger := zerolog.Nop()
|
||||
tlsCfg := &tls.Config{}
|
||||
|
||||
err := loadClientCA(TLSConfig{}, logger, tlsCfg, mockReadFileSuccess, mockStatFileSuccess)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, tlsCfg.ClientCAs)
|
||||
}
|
||||
|
||||
func TestNewCertPoolFromPEM_Invalid(t *testing.T) {
|
||||
_, err := newCertPoolFromPEM([]byte("invalid"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewCertPoolFromPEM_Valid(t *testing.T) {
|
||||
_, err := newCertPoolFromPEM([]byte(validPEM))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
1260
internal/configtypes/types.go
Normal file
1260
internal/configtypes/types.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user