DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+951
View File
@@ -0,0 +1,951 @@
package config
import (
"fmt"
"slices"
"strings"
"github.com/BurntSushi/toml"
)
const defaultWSPayloadMax = 1024 * 1024
const defaultWSHBSeconds = 10
const defaultRPCPayloadMax = 1024 * 1024
const defaultKCPWindowSize = 128
const defaultQUICStreams int64 = 128
const defRateWindow = 10
const defaultConnRateMax = 60
const defConnMaxEntries = 4096
const defConnIPv4Prefix = 32
const defConnIPv6Prefix = 64
const defaultEventLogTable = "outbox_events"
const defaultAccountTable = "account_center_state"
const defaultObjectTable = "storage_objects"
const defaultOutboxStream = "battle.settlement"
const defaultOutboxType = "battle.settlement.created"
func Load(path, defaultName string) (ServiceConfig, error) {
var cfg ServiceConfig
meta, err := toml.DecodeFile(path, &cfg)
if err != nil {
return ServiceConfig{}, err
}
if keys := undecodedTOMLKeys(meta); len(keys) > 0 {
return ServiceConfig{}, fmt.Errorf("unknown config keys: %s", strings.Join(keys, ", "))
}
expandEnvValues(&cfg)
cfg.Normalize(defaultName)
if err := cfg.Validate(); err != nil {
return ServiceConfig{}, err
}
return cfg, nil
}
func undecodedTOMLKeys(meta toml.MetaData) []string {
undecoded := meta.Undecoded()
if len(undecoded) == 0 {
return nil
}
keys := make([]string, 0, len(undecoded))
for _, key := range undecoded {
keys = append(keys, key.String())
}
slices.Sort(keys)
return keys
}
func (c *ServiceConfig) Normalize(defaultName string) {
c.trim()
if c.Service.Name == "" {
c.Service.Name = defaultName
}
if c.Service.NodeID == "" {
c.Service.NodeID = c.Service.Name + "-local-1"
}
if c.Service.Environment == "" {
c.Service.Environment = "local"
}
if c.Service.StopTimeoutSeconds == 0 {
c.Service.StopTimeoutSeconds = 10
}
if c.Service.StopInitialGraceSeconds == 0 {
c.Service.StopInitialGraceSeconds = 2
}
if c.Service.StopProbeSeconds == 0 {
c.Service.StopProbeSeconds = 1
}
if c.Service.StopStableSeconds == 0 {
c.Service.StopStableSeconds = 5
}
if c.Cluster.ID == "" {
c.Cluster.ID = "local"
}
if c.Cluster.VirtualGID == 0 {
c.Cluster.VirtualGID = c.Cluster.GID
}
if c.Cluster.Zone == "" {
c.Cluster.Zone = c.Service.Environment
}
if c.Cluster.Version == "" {
c.Cluster.Version = "local"
}
if c.Cluster.DataVersion == "" {
c.Cluster.DataVersion = "local"
}
if c.Cluster.HeartbeatIntervalSeconds == 0 {
c.Cluster.HeartbeatIntervalSeconds = 10
}
if c.Admin.Listen == "" {
c.Admin.Listen = ":18080"
}
for idx := range c.Admin.Tokens {
if c.Admin.Tokens[idx].Role == "" {
c.Admin.Tokens[idx].Role = "operator"
}
}
if c.Admin.ReadTimeoutSeconds == 0 {
c.Admin.ReadTimeoutSeconds = 10
}
if c.Admin.ReadHeaderTimeoutSeconds == 0 {
c.Admin.ReadHeaderTimeoutSeconds = 5
}
if c.Admin.WriteTimeoutSeconds == 0 {
c.Admin.WriteTimeoutSeconds = 15
}
if c.Admin.IdleTimeoutSeconds == 0 {
c.Admin.IdleTimeoutSeconds = 60
}
if c.RateLimit.Algorithm == "" {
c.RateLimit.Algorithm = "window"
}
if c.RateLimit.WindowSeconds == 0 {
c.RateLimit.WindowSeconds = 1
}
if c.RateLimit.MaxRequests == 0 {
c.RateLimit.MaxRequests = 60
}
if c.RateLimit.CleanupIntervalSeconds == 0 {
c.RateLimit.CleanupIntervalSeconds = 60
}
if c.Tracing.MaxSpans == 0 {
c.Tracing.MaxSpans = 256
}
if c.OTel.Endpoint == "" {
c.OTel.Endpoint = "127.0.0.1:4318"
}
if c.OTel.ServiceName == "" {
c.OTel.ServiceName = c.Service.Name
}
if c.OTel.SampleRatio == 0 {
c.OTel.SampleRatio = 1
}
if c.OTel.BatchTimeoutSeconds == 0 {
c.OTel.BatchTimeoutSeconds = 5
}
if c.DataTables.Directory == "" {
c.DataTables.Directory = "configs/tables"
}
if c.DataTables.Version == "" {
c.DataTables.Version = c.Cluster.DataVersion
}
if c.I18N.Directory == "" {
c.I18N.Directory = "configs/i18n"
}
if c.I18N.DefaultLanguage == "" {
c.I18N.DefaultLanguage = "zh-CN"
}
if c.I18N.Version == "" {
c.I18N.Version = c.Cluster.DataVersion
}
c.normalizePVF()
if c.Audit.Kind == "" {
c.Audit.Kind = "memory"
}
if c.Audit.FilePath == "" {
c.Audit.FilePath = "data/audit/events.jsonl"
}
if c.Audit.MemoryLimit == 0 {
c.Audit.MemoryLimit = 256
}
if c.LogicLog.Kind == "" {
c.LogicLog.Kind = "memory"
}
if c.LogicLog.FilePath == "" {
c.LogicLog.FilePath = "data/logiclog/events.jsonl"
}
if c.LogicLog.ReasonsPath == "" {
c.LogicLog.ReasonsPath = "configs/logiclog/reasons.csv"
}
if c.LogicLog.MemoryLimit == 0 {
c.LogicLog.MemoryLimit = 256
}
if c.BILog.Kind == "" {
c.BILog.Kind = "memory"
}
if c.BILog.FilePath == "" {
c.BILog.FilePath = "data/bilog/events.jsonl"
}
if c.BILog.SchemaPath == "" {
c.BILog.SchemaPath = "configs/bilog/schema.json"
}
if c.BILog.MemoryLimit == 0 {
c.BILog.MemoryLimit = 256
}
if c.Bus.NATSName == "" {
c.Bus.NATSName = "longheng-bus"
}
if c.Bus.NATSTimeoutSeconds == 0 {
c.Bus.NATSTimeoutSeconds = 5
}
if c.Bus.NATSPingIntervalSeconds == 0 {
c.Bus.NATSPingIntervalSeconds = 1
}
if c.Bus.NATSMaxPingsOutstanding == 0 {
c.Bus.NATSMaxPingsOutstanding = 10
}
if c.Bus.NATSMaxReconnects == 0 {
c.Bus.NATSMaxReconnects = -1
}
if c.Bus.NATSReconnectWaitSeconds == 0 {
c.Bus.NATSReconnectWaitSeconds = 1
}
if c.Bus.NATSWireEncoding == "" {
c.Bus.NATSWireEncoding = "binary_v1"
}
if c.EventLog.StoreKind == "" {
c.EventLog.StoreKind = "memory"
}
if c.EventLog.MySQLTable == "" {
c.EventLog.MySQLTable = defaultEventLogTable
}
if c.EventLog.MySQLMaxOpenConns == 0 {
c.EventLog.MySQLMaxOpenConns = 32
}
if c.EventLog.MySQLMaxIdleConns == 0 {
c.EventLog.MySQLMaxIdleConns = 8
}
if c.EventLog.MySQLConnMaxLifetime == 0 {
c.EventLog.MySQLConnMaxLifetime = 300
}
if c.EventLog.AdminPrefix == "" {
c.EventLog.AdminPrefix = "/eventlog"
}
if c.EventLog.PublishIntervalSeconds == 0 {
c.EventLog.PublishIntervalSeconds = 1
}
if c.EventLog.PublishBatchSize == 0 {
c.EventLog.PublishBatchSize = 100
}
if c.EventLog.PublishRetrySeconds == 0 {
c.EventLog.PublishRetrySeconds = 1
}
if c.EventLog.PublishMaxAttempts == 0 {
c.EventLog.PublishMaxAttempts = 3
}
if c.EventLog.PublishKind == "" {
c.EventLog.PublishKind = "bus"
}
if c.EventLog.PublishHTTPMethod == "" {
c.EventLog.PublishHTTPMethod = "POST"
}
if c.EventLog.PublishHTTPTimeout == 0 {
c.EventLog.PublishHTTPTimeout = 10
}
if c.EventLog.PublishHTTPPayload == "" {
c.EventLog.PublishHTTPPayload = "event"
}
if c.AccountCenter.StoreKind == "" {
c.AccountCenter.StoreKind = "memory"
}
if c.AccountCenter.MySQLTable == "" {
c.AccountCenter.MySQLTable = defaultAccountTable
}
if c.AccountCenter.AdminPrefix == "" {
c.AccountCenter.AdminPrefix = "/accountcenter"
}
if c.StorageObject.StoreKind == "" {
c.StorageObject.StoreKind = "memory"
}
if c.StorageObject.MySQLTable == "" {
c.StorageObject.MySQLTable = defaultObjectTable
}
if c.StorageObject.AdminPrefix == "" {
c.StorageObject.AdminPrefix = "/storage"
}
if c.BattleAgent.SettlementOutboxKind == "" {
c.BattleAgent.SettlementOutboxKind = "memory"
}
if c.BattleAgent.SettlementOutboxStream == "" {
c.BattleAgent.SettlementOutboxStream = defaultOutboxStream
}
if c.BattleAgent.SettlementOutboxType == "" {
c.BattleAgent.SettlementOutboxType = defaultOutboxType
}
if c.Reload.PollIntervalSeconds == 0 {
c.Reload.PollIntervalSeconds = 5
}
if c.Reload.ApplyTimeoutSeconds == 0 {
c.Reload.ApplyTimeoutSeconds = 5
}
if c.HotUpdate.Target == "" {
c.HotUpdate.Target = c.Service.Name
}
if c.HotUpdate.ControlKind == "" {
c.HotUpdate.ControlKind = "memory"
}
if c.HotUpdate.SourceKind == "" {
c.HotUpdate.SourceKind = "local"
}
if c.HotUpdate.ApplierKind == "" {
c.HotUpdate.ApplierKind = "datatable"
}
if c.HotUpdate.Workspace == "" {
c.HotUpdate.Workspace = "data/hotupdate"
}
if c.HotUpdate.ApplyTimeoutSeconds == 0 {
c.HotUpdate.ApplyTimeoutSeconds = 30
}
if c.HotUpdate.ApplierKind == "native_patch" && c.HotUpdate.NativePatchMaxLiveSeconds == 0 {
c.HotUpdate.NativePatchMaxLiveSeconds = 300
}
if c.StateSync.Kind == "" {
c.StateSync.Kind = "memory"
}
if c.StateSync.Namespace == "" {
c.StateSync.Namespace = c.Cluster.ID
}
if c.StateSync.TTLSeconds == 0 {
c.StateSync.TTLSeconds = 60
}
if c.StateSync.CleanupIntervalSeconds == 0 {
c.StateSync.CleanupIntervalSeconds = 30
}
if c.StateSync.MaxPayloadBytes == 0 {
c.StateSync.MaxPayloadBytes = 64 * 1024
}
if c.ServerGroup.StoreKind == "" {
c.ServerGroup.StoreKind = "file"
}
if c.ServerGroup.PlanFile == "" {
c.ServerGroup.PlanFile = "configs/servergroup/plan.json"
}
if c.ServerGroup.MergeArchiveDir == "" {
c.ServerGroup.MergeArchiveDir = "data/servergroup/merge_archives"
}
if c.ServerGroup.AdminPrefix == "" {
c.ServerGroup.AdminPrefix = "/debug/servergroup"
}
if c.ServerGroup.InZoneDelaySeconds == 0 {
c.ServerGroup.InZoneDelaySeconds = 24 * 60 * 60
}
if c.ServerGroup.NoticeLeadSeconds == 0 {
c.ServerGroup.NoticeLeadSeconds = 24 * 60 * 60
}
if len(c.Topology.Services) == 0 && c.Service.Name != "" {
c.Topology.Services = []string{c.Service.Name}
}
if c.Topology.GatewayService == "" {
c.Topology.GatewayService = "gateway"
}
if c.Topology.GatewayBackendService == "" {
if c.Gateway.BackendService != "" {
c.Topology.GatewayBackendService = c.Gateway.BackendService
} else {
c.Topology.GatewayBackendService = "logic"
}
}
if c.Topology.RefreshIntervalSeconds == 0 {
c.Topology.RefreshIntervalSeconds = 5
}
if c.Discovery.CacheTTLSeconds == 0 {
c.Discovery.CacheTTLSeconds = 1
}
if c.Discovery.FailureTTLSeconds == 0 {
c.Discovery.FailureTTLSeconds = 5
}
if c.Discovery.FailureThreshold == 0 {
c.Discovery.FailureThreshold = 1
}
if c.Discovery.Strategy == "" {
c.Discovery.Strategy = "hash"
}
if c.RPC.CallTimeoutSeconds == 0 {
c.RPC.CallTimeoutSeconds = 3
}
if c.RPC.MaxPending == 0 {
c.RPC.MaxPending = 1024
}
if c.RPC.MaxPayloadBytes == 0 {
c.RPC.MaxPayloadBytes = defaultRPCPayloadMax
}
if c.Idempotency.Kind == "" {
c.Idempotency.Kind = "memory"
}
if c.Idempotency.TTLSeconds == 0 {
c.Idempotency.TTLSeconds = 600
}
if c.Idempotency.KeyPrefix == "" {
c.Idempotency.KeyPrefix = "idempotency"
}
if c.Idempotency.RedisAddress == "" {
c.Idempotency.RedisAddress = "127.0.0.1:6379"
}
if c.Idempotency.RedisPoolSize == 0 {
c.Idempotency.RedisPoolSize = 8
}
if c.Idempotency.RedisTimeoutSeconds == 0 {
c.Idempotency.RedisTimeoutSeconds = 2
}
if c.Idempotency.RedisConnectTimeoutSecs == 0 {
c.Idempotency.RedisConnectTimeoutSecs = c.Idempotency.RedisTimeoutSeconds
}
if c.Idempotency.RedisReadTimeoutSecs == 0 {
c.Idempotency.RedisReadTimeoutSecs = c.Idempotency.RedisTimeoutSeconds
}
if c.Idempotency.RedisWriteTimeoutSecs == 0 {
c.Idempotency.RedisWriteTimeoutSecs = c.Idempotency.RedisTimeoutSeconds
}
if c.Idempotency.MySQLDSN == "" {
c.Idempotency.MySQLDSN = "longheng:longheng@tcp(127.0.0.1:3306)/longheng?parseTime=true&charset=utf8mb4,utf8"
}
if c.Idempotency.MySQLMaxOpenConns == 0 {
c.Idempotency.MySQLMaxOpenConns = 32
}
if c.Idempotency.MySQLMaxIdleConns == 0 {
c.Idempotency.MySQLMaxIdleConns = 8
}
if c.Idempotency.MySQLConnMaxLifetimeSec == 0 {
c.Idempotency.MySQLConnMaxLifetimeSec = 300
}
if c.Registry.Kind == "" {
c.Registry.Kind = "memory"
}
if c.Registry.Namespace == "" {
c.Registry.Namespace = "longheng"
}
if c.Registry.LeaseTTL == 0 {
c.Registry.LeaseTTL = 15
}
if c.Bus.Kind == "" {
c.Bus.Kind = "memory"
}
if c.Bus.Namespace == "" {
c.Bus.Namespace = "longheng"
}
if c.Cache.Kind == "" {
c.Cache.Kind = "memory"
}
if c.Cache.KeyPrefix == "" {
c.Cache.KeyPrefix = "cache"
}
if c.Cache.DefaultTTLSeconds == 0 {
c.Cache.DefaultTTLSeconds = 60
}
if c.Cache.MaxEntries == 0 {
c.Cache.MaxEntries = 10000
}
if c.Cache.RedisAddress == "" {
c.Cache.RedisAddress = "127.0.0.1:6379"
}
if c.Cache.RedisPoolSize == 0 {
c.Cache.RedisPoolSize = 8
}
if c.Cache.RedisTimeoutSeconds == 0 {
c.Cache.RedisTimeoutSeconds = 2
}
if c.Cache.RedisConnectTimeout == 0 {
c.Cache.RedisConnectTimeout = c.Cache.RedisTimeoutSeconds
}
if c.Cache.RedisReadTimeout == 0 {
c.Cache.RedisReadTimeout = c.Cache.RedisTimeoutSeconds
}
if c.Cache.RedisWriteTimeout == 0 {
c.Cache.RedisWriteTimeout = c.Cache.RedisTimeoutSeconds
}
if c.Lock.Kind == "" {
c.Lock.Kind = "memory"
}
if c.Lock.KeyPrefix == "" {
c.Lock.KeyPrefix = "lock"
}
if c.Lock.TTLSeconds == 0 {
c.Lock.TTLSeconds = 10
}
if c.Lock.RedisAddress == "" {
c.Lock.RedisAddress = "127.0.0.1:6379"
}
if c.Lock.RedisPoolSize == 0 {
c.Lock.RedisPoolSize = 8
}
if c.Lock.RedisTimeoutSeconds == 0 {
c.Lock.RedisTimeoutSeconds = 2
}
if c.Lock.RedisConnectTimeout == 0 {
c.Lock.RedisConnectTimeout = c.Lock.RedisTimeoutSeconds
}
if c.Lock.RedisReadTimeout == 0 {
c.Lock.RedisReadTimeout = c.Lock.RedisTimeoutSeconds
}
if c.Lock.RedisWriteTimeout == 0 {
c.Lock.RedisWriteTimeout = c.Lock.RedisTimeoutSeconds
}
if c.Presence.Kind == "" {
c.Presence.Kind = "memory"
}
if c.Presence.KeyPrefix == "" {
c.Presence.KeyPrefix = "presence"
}
if c.Presence.TTLSeconds == 0 {
c.Presence.TTLSeconds = c.Gateway.SessionIdleTimeoutSeconds
if c.Presence.TTLSeconds == 0 {
c.Presence.TTLSeconds = 300
}
}
if c.Presence.RedisAddress == "" {
c.Presence.RedisAddress = "127.0.0.1:6379"
}
if c.Presence.RedisPoolSize == 0 {
c.Presence.RedisPoolSize = 8
}
if c.Presence.RedisTimeoutSeconds == 0 {
c.Presence.RedisTimeoutSeconds = 2
}
if c.Presence.RedisConnectTimeout == 0 {
c.Presence.RedisConnectTimeout = c.Presence.RedisTimeoutSeconds
}
if c.Presence.RedisReadTimeout == 0 {
c.Presence.RedisReadTimeout = c.Presence.RedisTimeoutSeconds
}
if c.Presence.RedisWriteTimeout == 0 {
c.Presence.RedisWriteTimeout = c.Presence.RedisTimeoutSeconds
}
if c.Worker.Size == 0 {
c.Worker.Size = 4
}
if c.Worker.Queue == 0 {
c.Worker.Queue = 128
}
if c.ProfileStore.StoreKind == "" {
c.ProfileStore.StoreKind = "memory"
}
if c.ProfileStore.StoreDirectory == "" {
c.ProfileStore.StoreDirectory = "data/profiles"
}
if c.ProfileStore.StoreAddress == "" {
c.ProfileStore.StoreAddress = "127.0.0.1:6379"
}
if c.ProfileStore.StoreKeyPrefix == "" {
c.ProfileStore.StoreKeyPrefix = "profile"
}
if c.ProfileStore.StorePoolSize == 0 {
c.ProfileStore.StorePoolSize = 8
}
if c.ProfileStore.StoreTimeoutSeconds == 0 {
c.ProfileStore.StoreTimeoutSeconds = 2
}
if c.ProfileStore.StoreConnectTimeout == 0 {
c.ProfileStore.StoreConnectTimeout = c.ProfileStore.StoreTimeoutSeconds
}
if c.ProfileStore.StoreReadTimeout == 0 {
c.ProfileStore.StoreReadTimeout = c.ProfileStore.StoreTimeoutSeconds
}
if c.ProfileStore.StoreWriteTimeout == 0 {
c.ProfileStore.StoreWriteTimeout = c.ProfileStore.StoreTimeoutSeconds
}
if c.ProfileStore.MySQLDSN == "" {
c.ProfileStore.MySQLDSN = "longheng:longheng@tcp(127.0.0.1:3306)/longheng?parseTime=true&charset=utf8mb4,utf8"
}
if c.ProfileStore.MySQLMaxOpenConns == 0 {
c.ProfileStore.MySQLMaxOpenConns = 32
}
if c.ProfileStore.MySQLMaxIdleConns == 0 {
c.ProfileStore.MySQLMaxIdleConns = 8
}
if c.ProfileStore.MySQLConnMaxLifetime == 0 {
c.ProfileStore.MySQLConnMaxLifetime = 300
}
if c.ProfileStore.SummaryStoreKind == "" {
if c.ProfileStore.StoreKind == "mysql_redis" {
c.ProfileStore.SummaryStoreKind = "redis"
} else {
c.ProfileStore.SummaryStoreKind = "memory"
}
}
if c.ProfileStore.SummaryStoreAddress == "" {
c.ProfileStore.SummaryStoreAddress = c.ProfileStore.StoreAddress
}
if c.ProfileStore.SummaryStoreKeyPrefix == "" {
c.ProfileStore.SummaryStoreKeyPrefix = "profile_summary"
}
if c.ProfileStore.SummaryStorePoolSize == 0 {
c.ProfileStore.SummaryStorePoolSize = c.ProfileStore.StorePoolSize
}
if c.ProfileStore.SummaryStoreTimeout == 0 {
c.ProfileStore.SummaryStoreTimeout = c.ProfileStore.StoreTimeoutSeconds
}
if c.ProfileStore.SummaryStoreConnectTimeout == 0 {
c.ProfileStore.SummaryStoreConnectTimeout = c.ProfileStore.SummaryStoreTimeout
}
if c.ProfileStore.SummaryStoreReadTimeout == 0 {
c.ProfileStore.SummaryStoreReadTimeout = c.ProfileStore.SummaryStoreTimeout
}
if c.ProfileStore.SummaryStoreWriteTimeout == 0 {
c.ProfileStore.SummaryStoreWriteTimeout = c.ProfileStore.SummaryStoreTimeout
}
if c.ProfileStore.SaveMode == "" {
c.ProfileStore.SaveMode = "sync"
}
if c.ProfileStore.AsyncFlushIntervalSeconds == 0 {
c.ProfileStore.AsyncFlushIntervalSeconds = 5
}
if c.ProfileStore.AsyncMaxPending == 0 {
c.ProfileStore.AsyncMaxPending = 128
}
if c.ProfileStore.AsyncRetryBackoffSeconds == 0 {
c.ProfileStore.AsyncRetryBackoffSeconds = 1
}
if c.ProfileStore.AsyncDeadLetterLimit == 0 {
c.ProfileStore.AsyncDeadLetterLimit = 128
}
if c.ProfileStore.AsyncDeadLetterDirectory == "" {
c.ProfileStore.AsyncDeadLetterDirectory = "data/profile_dead_letters"
}
if c.Gateway.AuthListen == "" {
c.Gateway.AuthListen = ":8081"
}
if c.Gateway.AuthMode == "" {
c.Gateway.AuthMode = "mock"
}
if c.Gateway.GateRouteFeature == "" {
c.Gateway.GateRouteFeature = "gateway"
}
if c.Gateway.GateService == "" {
if c.Topology.GatewayService != "" {
c.Gateway.GateService = c.Topology.GatewayService
} else {
c.Gateway.GateService = c.Service.Name
}
}
if c.Gateway.TCPListen == "" {
c.Gateway.TCPListen = ":3341"
}
if c.Gateway.TCPProxyProtocolEnabled && c.Gateway.TCPProxyHeaderSec == 0 {
c.Gateway.TCPProxyHeaderSec = 3
}
if c.Gateway.KCPEnabled {
if c.Gateway.KCPListen == "" {
c.Gateway.KCPListen = ":3342"
}
if c.Gateway.KCPNoDelay == 0 {
c.Gateway.KCPNoDelay = 1
}
if c.Gateway.KCPInterval == 0 {
c.Gateway.KCPInterval = 10
}
if c.Gateway.KCPResend == 0 {
c.Gateway.KCPResend = 2
}
if c.Gateway.KCPNoCongestion == 0 {
c.Gateway.KCPNoCongestion = 1
}
if c.Gateway.KCPWindowSize == 0 {
c.Gateway.KCPWindowSize = defaultKCPWindowSize
}
}
if c.Gateway.QUICEnabled {
if c.Gateway.QUICListen == "" {
c.Gateway.QUICListen = ":3343"
}
if c.Gateway.QUICHandshakeSec == 0 {
c.Gateway.QUICHandshakeSec = 5
}
if c.Gateway.QUICMaxIdleTimeoutSeconds == 0 {
c.Gateway.QUICMaxIdleTimeoutSeconds = c.Gateway.SessionIdleTimeoutSeconds
if c.Gateway.QUICMaxIdleTimeoutSeconds == 0 {
c.Gateway.QUICMaxIdleTimeoutSeconds = 300
}
}
if c.Gateway.QUICKeepAliveSeconds == 0 {
c.Gateway.QUICKeepAliveSeconds = 10
}
if c.Gateway.QUICMaxIncomingStreams == 0 {
c.Gateway.QUICMaxIncomingStreams = defaultQUICStreams
}
}
if c.Gateway.WebSocketPath == "" {
c.Gateway.WebSocketPath = "/ws"
}
if c.Gateway.WebSocketEnabled && c.Gateway.WebSocketListen == "" {
c.Gateway.WebSocketListen = ":8082"
}
if c.Gateway.WebSocketMaxPayloadBytes == 0 {
c.Gateway.WebSocketMaxPayloadBytes = defaultWSPayloadMax
}
if c.Gateway.WebSocketHeartbeatSeconds == 0 {
c.Gateway.WebSocketHeartbeatSeconds = defaultWSHBSeconds
}
if c.Gateway.ChatListen == "" {
c.Gateway.ChatListen = "127.0.0.1:10001"
}
if c.Gateway.BackendService == "" {
c.Gateway.BackendService = "logic"
}
if c.Gateway.BackendPollIntervalSeconds == 0 {
c.Gateway.BackendPollIntervalSeconds = 5
}
if c.Gateway.MaxConnections == 0 {
c.Gateway.MaxConnections = 1024
}
if c.Gateway.ConnRateWindowSec == 0 {
c.Gateway.ConnRateWindowSec = defRateWindow
}
if c.Gateway.ConnectionRateLimitMax == 0 {
c.Gateway.ConnectionRateLimitMax = defaultConnRateMax
}
if c.Gateway.ConnRateMaxEntries == 0 {
c.Gateway.ConnRateMaxEntries = defConnMaxEntries
}
if c.Gateway.ConnRateIPv4Prefix == 0 {
c.Gateway.ConnRateIPv4Prefix = defConnIPv4Prefix
}
if c.Gateway.ConnRateIPv6Prefix == 0 {
c.Gateway.ConnRateIPv6Prefix = defConnIPv6Prefix
}
if c.Gateway.SessionIdleTimeoutSeconds == 0 {
c.Gateway.SessionIdleTimeoutSeconds = 300
}
if c.Gateway.SessionSweepSec == 0 {
c.Gateway.SessionSweepSec = 30
}
if c.Gateway.SessionHookTimeoutSeconds == 0 {
c.Gateway.SessionHookTimeoutSeconds = 3
}
if c.Gateway.AccountID == "" {
c.Gateway.AccountID = "local_account_10001"
}
if c.Gateway.AccountNumID == 0 {
c.Gateway.AccountNumID = 10001
}
if c.Gateway.AuthMode == "mock" && c.Gateway.AuthToken == "" {
c.Gateway.AuthToken = "local-auth-token"
}
if c.Gateway.AuthMode == "mock" && c.Gateway.LoginToken == "" {
c.Gateway.LoginToken = "local-login-token"
}
if c.Gateway.ServerVersion == "" {
c.Gateway.ServerVersion = "local-gateway 1.0"
}
if c.Gateway.ShardID == 0 {
c.Gateway.ShardID = 9999
}
if c.Gateway.ShardName == "" {
c.Gateway.ShardName = "9999"
}
if c.Gateway.ShardDisplay == "" {
c.Gateway.ShardDisplay = "9999.Local"
}
if c.Gateway.ShardState == 0 {
c.Gateway.ShardState = 1
}
if c.Gateway.Recommend == 0 {
c.Gateway.Recommend = 1
}
if c.Gateway.RecommendLang == "" {
c.Gateway.RecommendLang = "ZH"
}
if c.Gateway.VirtualGID == 0 {
c.Gateway.VirtualGID = 2
}
if len(c.Gateway.ProductIDs) == 0 {
c.Gateway.ProductIDs = []string{"1", "2", "3", "4", "25", "1001"}
}
if !c.Gateway.HasRole {
c.Gateway.SendCreatePush = true
}
}
func (c *ServiceConfig) trim() {
c.Service.Name = strings.TrimSpace(c.Service.Name)
c.Service.NodeID = strings.TrimSpace(c.Service.NodeID)
c.Service.Environment = strings.TrimSpace(c.Service.Environment)
c.Service.PublicAddr = strings.TrimSpace(c.Service.PublicAddr)
c.Service.PrivateAddr = strings.TrimSpace(c.Service.PrivateAddr)
c.Cluster.ID = strings.TrimSpace(c.Cluster.ID)
c.Cluster.Zone = strings.TrimSpace(c.Cluster.Zone)
c.Cluster.Version = strings.TrimSpace(c.Cluster.Version)
c.Cluster.DataVersion = strings.TrimSpace(c.Cluster.DataVersion)
c.Admin.Listen = strings.TrimSpace(c.Admin.Listen)
c.Admin.Token = strings.TrimSpace(c.Admin.Token)
for idx := range c.Admin.Tokens {
c.Admin.Tokens[idx].Name = strings.TrimSpace(c.Admin.Tokens[idx].Name)
c.Admin.Tokens[idx].Token = strings.TrimSpace(c.Admin.Tokens[idx].Token)
c.Admin.Tokens[idx].Role = strings.ToLower(strings.TrimSpace(c.Admin.Tokens[idx].Role))
c.Admin.Tokens[idx].Scopes = trimLowerStringSlice(c.Admin.Tokens[idx].Scopes)
}
c.OTel.Endpoint = strings.TrimSpace(c.OTel.Endpoint)
c.OTel.ServiceName = strings.TrimSpace(c.OTel.ServiceName)
c.DataTables.Directory = strings.TrimSpace(c.DataTables.Directory)
c.DataTables.Version = strings.TrimSpace(c.DataTables.Version)
c.I18N.Directory = strings.TrimSpace(c.I18N.Directory)
c.I18N.DefaultLanguage = strings.TrimSpace(c.I18N.DefaultLanguage)
c.I18N.Version = strings.TrimSpace(c.I18N.Version)
c.trimPVF()
c.Audit.Kind = strings.ToLower(strings.TrimSpace(c.Audit.Kind))
c.Audit.FilePath = strings.TrimSpace(c.Audit.FilePath)
c.LogicLog.Kind = strings.ToLower(strings.TrimSpace(c.LogicLog.Kind))
c.LogicLog.FilePath = strings.TrimSpace(c.LogicLog.FilePath)
c.LogicLog.ReasonsPath = strings.TrimSpace(c.LogicLog.ReasonsPath)
c.BILog.Kind = strings.ToLower(strings.TrimSpace(c.BILog.Kind))
c.BILog.FilePath = strings.TrimSpace(c.BILog.FilePath)
c.BILog.SchemaPath = strings.TrimSpace(c.BILog.SchemaPath)
c.EventLog.StoreKind = strings.ToLower(strings.TrimSpace(c.EventLog.StoreKind))
c.EventLog.MySQLDSN = strings.TrimSpace(c.EventLog.MySQLDSN)
c.EventLog.MySQLTable = strings.TrimSpace(c.EventLog.MySQLTable)
c.EventLog.AdminPrefix = strings.TrimSpace(c.EventLog.AdminPrefix)
c.EventLog.PublishKind = strings.ToLower(strings.TrimSpace(c.EventLog.PublishKind))
c.EventLog.PublishHTTPURL = strings.TrimSpace(c.EventLog.PublishHTTPURL)
c.EventLog.PublishHTTPMethod = strings.ToUpper(strings.TrimSpace(c.EventLog.PublishHTTPMethod))
c.EventLog.PublishHTTPPayload = strings.ToLower(strings.TrimSpace(c.EventLog.PublishHTTPPayload))
c.EventLog.PublishHTTPHeaders = trimStringMap(c.EventLog.PublishHTTPHeaders)
c.AccountCenter.StoreKind = strings.ToLower(strings.TrimSpace(c.AccountCenter.StoreKind))
c.AccountCenter.MySQLDSN = strings.TrimSpace(c.AccountCenter.MySQLDSN)
c.AccountCenter.MySQLTable = strings.TrimSpace(c.AccountCenter.MySQLTable)
c.AccountCenter.AdminPrefix = strings.TrimSpace(c.AccountCenter.AdminPrefix)
c.StorageObject.StoreKind = strings.ToLower(strings.TrimSpace(c.StorageObject.StoreKind))
c.StorageObject.MySQLDSN = strings.TrimSpace(c.StorageObject.MySQLDSN)
c.StorageObject.MySQLTable = strings.TrimSpace(c.StorageObject.MySQLTable)
c.StorageObject.AdminPrefix = strings.TrimSpace(c.StorageObject.AdminPrefix)
c.BattleAgent.SettlementOutboxKind = strings.ToLower(strings.TrimSpace(c.BattleAgent.SettlementOutboxKind))
c.BattleAgent.SettlementOutboxStream = strings.TrimSpace(c.BattleAgent.SettlementOutboxStream)
c.BattleAgent.SettlementOutboxType = strings.TrimSpace(c.BattleAgent.SettlementOutboxType)
c.HotUpdate.Target = strings.TrimSpace(c.HotUpdate.Target)
c.HotUpdate.ControlKind = strings.ToLower(strings.TrimSpace(c.HotUpdate.ControlKind))
c.HotUpdate.SourceKind = strings.ToLower(strings.TrimSpace(c.HotUpdate.SourceKind))
c.HotUpdate.ApplierKind = strings.ToLower(strings.TrimSpace(c.HotUpdate.ApplierKind))
c.HotUpdate.Workspace = strings.TrimSpace(c.HotUpdate.Workspace)
c.HotUpdate.SigningKeyEnv = strings.TrimSpace(c.HotUpdate.SigningKeyEnv)
c.HotUpdate.NativePatchAllowedTargets = trimStringSlice(c.HotUpdate.NativePatchAllowedTargets)
c.HotUpdate.PatchOldSymbols = trimStringSlice(c.HotUpdate.PatchOldSymbols)
c.StateSync.Kind = strings.ToLower(strings.TrimSpace(c.StateSync.Kind))
c.StateSync.Namespace = strings.TrimSpace(c.StateSync.Namespace)
c.ServerGroup.StoreKind = strings.ToLower(strings.TrimSpace(c.ServerGroup.StoreKind))
c.ServerGroup.PlanFile = strings.TrimSpace(c.ServerGroup.PlanFile)
c.ServerGroup.MergeArchiveDir = strings.TrimSpace(c.ServerGroup.MergeArchiveDir)
c.ServerGroup.AdminPrefix = strings.TrimSpace(c.ServerGroup.AdminPrefix)
c.Topology.Services = trimStringSlice(c.Topology.Services)
c.Topology.GatewayService = strings.TrimSpace(c.Topology.GatewayService)
c.Topology.GatewayBackendService = strings.TrimSpace(c.Topology.GatewayBackendService)
c.Discovery.Strategy = strings.ToLower(strings.TrimSpace(c.Discovery.Strategy))
c.RateLimit.Algorithm = strings.ToLower(strings.TrimSpace(c.RateLimit.Algorithm))
if c.RateLimit.Algorithm == "tokenbucket" || c.RateLimit.Algorithm == "token-bucket" {
c.RateLimit.Algorithm = "token_bucket"
}
c.RateLimit.Rules = strings.TrimSpace(c.RateLimit.Rules)
c.RateLimit.TrustedHeader = strings.TrimSpace(c.RateLimit.TrustedHeader)
c.Idempotency.Kind = strings.ToLower(strings.TrimSpace(c.Idempotency.Kind))
c.Idempotency.KeyPrefix = strings.TrimSpace(c.Idempotency.KeyPrefix)
c.Idempotency.RedisAddress = strings.TrimSpace(c.Idempotency.RedisAddress)
c.Idempotency.RedisPassword = strings.TrimSpace(c.Idempotency.RedisPassword)
c.Idempotency.MySQLDSN = strings.TrimSpace(c.Idempotency.MySQLDSN)
c.Registry.Kind = strings.ToLower(strings.TrimSpace(c.Registry.Kind))
c.Registry.Namespace = strings.TrimSpace(c.Registry.Namespace)
c.Registry.Endpoints = trimStringSlice(c.Registry.Endpoints)
c.Registry.RedisPassword = strings.TrimSpace(c.Registry.RedisPassword)
c.Bus.Kind = strings.ToLower(strings.TrimSpace(c.Bus.Kind))
c.Bus.Namespace = strings.TrimSpace(c.Bus.Namespace)
c.Bus.Endpoints = trimStringSlice(c.Bus.Endpoints)
c.Bus.RedisUsername = strings.TrimSpace(c.Bus.RedisUsername)
c.Bus.RedisPassword = strings.TrimSpace(c.Bus.RedisPassword)
c.Bus.RedisTLSServerName = strings.TrimSpace(c.Bus.RedisTLSServerName)
c.Bus.NATSToken = strings.TrimSpace(c.Bus.NATSToken)
c.Bus.NATSUsername = strings.TrimSpace(c.Bus.NATSUsername)
c.Bus.NATSPassword = strings.TrimSpace(c.Bus.NATSPassword)
c.Bus.NATSCredentials = strings.TrimSpace(c.Bus.NATSCredentials)
c.Bus.NATSTLSServerName = strings.TrimSpace(c.Bus.NATSTLSServerName)
c.Bus.NATSCAFile = strings.TrimSpace(c.Bus.NATSCAFile)
c.Bus.NATSCertFile = strings.TrimSpace(c.Bus.NATSCertFile)
c.Bus.NATSKeyFile = strings.TrimSpace(c.Bus.NATSKeyFile)
c.Cache.Kind = strings.ToLower(strings.TrimSpace(c.Cache.Kind))
c.Cache.KeyPrefix = strings.TrimSpace(c.Cache.KeyPrefix)
c.Cache.RedisAddress = strings.TrimSpace(c.Cache.RedisAddress)
c.Cache.RedisPassword = strings.TrimSpace(c.Cache.RedisPassword)
c.Lock.Kind = strings.ToLower(strings.TrimSpace(c.Lock.Kind))
c.Lock.KeyPrefix = strings.TrimSpace(c.Lock.KeyPrefix)
c.Lock.RedisAddress = strings.TrimSpace(c.Lock.RedisAddress)
c.Lock.RedisPassword = strings.TrimSpace(c.Lock.RedisPassword)
c.Presence.Kind = strings.ToLower(strings.TrimSpace(c.Presence.Kind))
c.Presence.KeyPrefix = strings.TrimSpace(c.Presence.KeyPrefix)
c.Presence.RedisAddress = strings.TrimSpace(c.Presence.RedisAddress)
c.Presence.RedisPassword = strings.TrimSpace(c.Presence.RedisPassword)
c.ProfileStore.StoreKind = strings.ToLower(strings.TrimSpace(c.ProfileStore.StoreKind))
c.ProfileStore.StoreDirectory = strings.TrimSpace(c.ProfileStore.StoreDirectory)
c.ProfileStore.StoreAddress = strings.TrimSpace(c.ProfileStore.StoreAddress)
c.ProfileStore.StorePassword = strings.TrimSpace(c.ProfileStore.StorePassword)
c.ProfileStore.StoreKeyPrefix = strings.TrimSpace(c.ProfileStore.StoreKeyPrefix)
c.ProfileStore.MySQLDSN = strings.TrimSpace(c.ProfileStore.MySQLDSN)
for idx := range c.ProfileStore.MySQLShards {
c.ProfileStore.MySQLShards[idx].ID = strings.TrimSpace(c.ProfileStore.MySQLShards[idx].ID)
c.ProfileStore.MySQLShards[idx].DSN = strings.TrimSpace(c.ProfileStore.MySQLShards[idx].DSN)
c.ProfileStore.MySQLShards[idx].TableName = strings.TrimSpace(c.ProfileStore.MySQLShards[idx].TableName)
c.ProfileStore.MySQLShards[idx].TablePrefix = strings.TrimSpace(c.ProfileStore.MySQLShards[idx].TablePrefix)
c.ProfileStore.MySQLShards[idx].HashSlots = strings.TrimSpace(c.ProfileStore.MySQLShards[idx].HashSlots)
}
c.ProfileStore.SummaryStoreKind = strings.ToLower(strings.TrimSpace(c.ProfileStore.SummaryStoreKind))
c.ProfileStore.SummaryStoreAddress = strings.TrimSpace(c.ProfileStore.SummaryStoreAddress)
c.ProfileStore.SummaryStorePassword = strings.TrimSpace(c.ProfileStore.SummaryStorePassword)
c.ProfileStore.SummaryStoreKeyPrefix = strings.TrimSpace(c.ProfileStore.SummaryStoreKeyPrefix)
c.ProfileStore.SaveMode = strings.ToLower(strings.TrimSpace(c.ProfileStore.SaveMode))
c.ProfileStore.AsyncDeadLetterDirectory = strings.TrimSpace(c.ProfileStore.AsyncDeadLetterDirectory)
c.Gateway.AuthListen = strings.TrimSpace(c.Gateway.AuthListen)
c.Gateway.AuthMode = strings.ToLower(strings.TrimSpace(c.Gateway.AuthMode))
c.Gateway.GateRouteFeature = strings.TrimSpace(c.Gateway.GateRouteFeature)
c.Gateway.GateService = strings.TrimSpace(c.Gateway.GateService)
c.Gateway.TCPListen = strings.TrimSpace(c.Gateway.TCPListen)
c.Gateway.TCPProxyTrustedCIDRs = trimStringSlice(c.Gateway.TCPProxyTrustedCIDRs)
c.Gateway.KCPListen = strings.TrimSpace(c.Gateway.KCPListen)
c.Gateway.QUICListen = strings.TrimSpace(c.Gateway.QUICListen)
c.Gateway.QUICCertFile = strings.TrimSpace(c.Gateway.QUICCertFile)
c.Gateway.QUICKeyFile = strings.TrimSpace(c.Gateway.QUICKeyFile)
c.Gateway.WebSocketListen = strings.TrimSpace(c.Gateway.WebSocketListen)
c.Gateway.WebSocketPath = strings.TrimSpace(c.Gateway.WebSocketPath)
c.Gateway.ChatListen = strings.TrimSpace(c.Gateway.ChatListen)
c.Gateway.BackendService = strings.TrimSpace(c.Gateway.BackendService)
c.Gateway.AccountID = strings.TrimSpace(c.Gateway.AccountID)
c.Gateway.SessionKeyMasterSecret = strings.TrimSpace(c.Gateway.SessionKeyMasterSecret)
c.Gateway.AuthToken = strings.TrimSpace(c.Gateway.AuthToken)
c.Gateway.LoginToken = strings.TrimSpace(c.Gateway.LoginToken)
c.Gateway.ServerVersion = strings.TrimSpace(c.Gateway.ServerVersion)
c.Gateway.ShardName = strings.TrimSpace(c.Gateway.ShardName)
c.Gateway.ShardDisplay = strings.TrimSpace(c.Gateway.ShardDisplay)
c.Gateway.RecommendLang = strings.TrimSpace(c.Gateway.RecommendLang)
c.Gateway.ProductIDs = trimStringSlice(c.Gateway.ProductIDs)
}
func trimStringSlice(values []string) []string {
for idx := range values {
values[idx] = strings.TrimSpace(values[idx])
}
return values
}
func trimLowerStringSlice(values []string) []string {
for idx := range values {
values[idx] = strings.ToLower(strings.TrimSpace(values[idx]))
}
return values
}
func trimStringMap(values map[string]string) map[string]string {
if len(values) == 0 {
return nil
}
out := make(map[string]string, len(values))
for key, value := range values {
key = strings.TrimSpace(key)
if key == "" {
continue
}
out[key] = strings.TrimSpace(value)
}
return out
}
@@ -0,0 +1,47 @@
package config
import "strings"
const (
defaultPVFMaxBytes int64 = 1024 * 1024 * 1024
defaultPVFChunkCacheBytes int64 = 96 * 1024 * 1024
defaultPVFTextCacheBytes int64 = 64 * 1024 * 1024
)
// PVFSection is the DNF project extension that keeps Script.pvf loading in
// the generic platform bootstrap without coupling the framework to DNF logic.
type PVFSection struct {
Enabled bool `toml:"enabled"`
Path string `toml:"path"`
MaxBytes int64 `toml:"max_bytes"`
PreloadChunks bool `toml:"preload_chunks"`
ChunkCacheBytes int64 `toml:"chunk_cache_bytes"`
TextCacheBytes int64 `toml:"text_cache_bytes"`
}
func (c *ServiceConfig) normalizePVF() {
if c.PVF.MaxBytes == 0 {
c.PVF.MaxBytes = defaultPVFMaxBytes
}
if c.PVF.ChunkCacheBytes == 0 {
c.PVF.ChunkCacheBytes = defaultPVFChunkCacheBytes
}
if c.PVF.TextCacheBytes == 0 {
c.PVF.TextCacheBytes = defaultPVFTextCacheBytes
}
}
func (c *ServiceConfig) trimPVF() {
c.PVF.Path = strings.TrimSpace(c.PVF.Path)
}
func (c ServiceConfig) validatePVF(errs *[]error) {
if c.PVF.Enabled {
requireString(errs, "pvf.path", c.PVF.Path)
}
if c.PVF.MaxBytes != -1 {
requirePositiveInt64(errs, "pvf.max_bytes", c.PVF.MaxBytes)
}
requirePositiveInt64(errs, "pvf.chunk_cache_bytes", c.PVF.ChunkCacheBytes)
requirePositiveInt64(errs, "pvf.text_cache_bytes", c.PVF.TextCacheBytes)
}
@@ -0,0 +1,12 @@
package config
import "testing"
func TestNormalizePVFUsesOneGiBDefaultLimit(t *testing.T) {
var cfg ServiceConfig
cfg.normalizePVF()
const want int64 = 1024 * 1024 * 1024
if cfg.PVF.MaxBytes != want {
t.Fatalf("PVF.MaxBytes = %d, want %d", cfg.PVF.MaxBytes, want)
}
}
@@ -0,0 +1,35 @@
package config
import "time"
func (s ServiceSection) StopTimeout() time.Duration {
return time.Duration(s.StopTimeoutSeconds) * time.Second
}
func (s ServiceSection) StopInitialGrace() time.Duration {
return time.Duration(s.StopInitialGraceSeconds) * time.Second
}
func (s ServiceSection) StopProbeInterval() time.Duration {
return time.Duration(s.StopProbeSeconds) * time.Second
}
func (s ServiceSection) StopStableWindow() time.Duration {
return time.Duration(s.StopStableSeconds) * time.Second
}
func (a AdminSection) ReadTimeout() time.Duration {
return time.Duration(a.ReadTimeoutSeconds) * time.Second
}
func (a AdminSection) ReadHeaderTimeout() time.Duration {
return time.Duration(a.ReadHeaderTimeoutSeconds) * time.Second
}
func (a AdminSection) WriteTimeout() time.Duration {
return time.Duration(a.WriteTimeoutSeconds) * time.Second
}
func (a AdminSection) IdleTimeout() time.Duration {
return time.Duration(a.IdleTimeoutSeconds) * time.Second
}
@@ -0,0 +1,71 @@
package config
import (
"os"
"reflect"
"regexp"
)
var configEnvPattern = regexp.MustCompile(`\$\{[A-Za-z_][A-Za-z0-9_]*\}`)
func expandEnvString(value string) string {
return configEnvPattern.ReplaceAllStringFunc(value, func(token string) string {
key := token[2 : len(token)-1]
if value, ok := os.LookupEnv(key); ok {
return value
}
return token
})
}
func expandEnvValues(target any) {
expandConfigEnvValue(reflect.ValueOf(target))
}
func expandConfigEnvValue(value reflect.Value) {
if !value.IsValid() {
return
}
if value.Kind() == reflect.Pointer {
if value.IsNil() {
return
}
expandConfigEnvValue(value.Elem())
return
}
switch value.Kind() {
case reflect.Struct:
for idx := 0; idx < value.NumField(); idx++ {
field := value.Field(idx)
if !field.CanSet() && field.Kind() != reflect.Struct {
continue
}
expandConfigEnvValue(field)
}
case reflect.String:
if value.CanSet() {
value.SetString(expandEnvString(value.String()))
}
case reflect.Slice, reflect.Array:
for idx := 0; idx < value.Len(); idx++ {
expandConfigEnvValue(value.Index(idx))
}
case reflect.Map:
if value.IsNil() || !value.CanSet() {
return
}
replacement := reflect.MakeMapWithSize(value.Type(), value.Len())
for _, key := range value.MapKeys() {
outKey := key
outValue := value.MapIndex(key)
if key.Kind() == reflect.String {
outKey = reflect.ValueOf(expandEnvString(key.String())).Convert(key.Type())
}
if outValue.Kind() == reflect.String {
outValue = reflect.ValueOf(expandEnvString(outValue.String())).Convert(outValue.Type())
}
replacement.SetMapIndex(outKey, outValue)
}
value.Set(replacement)
}
}
@@ -0,0 +1,317 @@
package config
import (
"fmt"
"os"
"reflect"
"strings"
)
func validateProdSafety(errs *[]error, cfg ServiceConfig) {
// 生产类环境不能只看 environment=prod,k8s/helm/staging 也按上线标准 fail-fast。
validEnvPlaceholders(errs, reflect.ValueOf(cfg), "")
rejectProdDefault(errs, "cluster.id", cfg.Cluster.ID)
rejectProdDefault(errs, "cluster.version", cfg.Cluster.Version)
rejectProdDefault(errs, "cluster.data_version", cfg.Cluster.DataVersion)
rejectKnownSecret(errs, "admin.token", cfg.Admin.Token)
if len(cfg.Admin.Tokens) == 0 {
*errs = append(*errs, fmt.Errorf("admin.tokens requires at least one scoped token for production-like environments"))
}
if !cfg.Metrics.RequireAdminToken {
*errs = append(*errs, fmt.Errorf("metrics.require_admin_token is required for production-like environments"))
}
if cfg.HotUpdate.Enabled && !cfg.HotUpdate.RequireSignature {
*errs = append(*errs, fmt.Errorf("hot_update.require_signature is required for production-like environments"))
}
if cfg.Bus.Kind == "" || cfg.Bus.Kind == "memory" {
*errs = append(*errs, fmt.Errorf("bus.kind memory is not allowed for production-like environments"))
}
if cfg.Cache.Kind == "" || cfg.Cache.Kind == "memory" {
*errs = append(*errs, fmt.Errorf("cache.kind memory is not allowed for production-like environments"))
}
if cfg.Lock.Kind == "" || cfg.Lock.Kind == "memory" {
*errs = append(*errs, fmt.Errorf("lock.kind memory is not allowed for production-like environments"))
}
if cfg.Registry.Kind == "" || cfg.Registry.Kind == "memory" {
*errs = append(*errs, fmt.Errorf("registry.kind memory is not allowed for production-like environments"))
}
if !cfg.Tracing.Enabled && !cfg.OTel.Enabled {
*errs = append(*errs, fmt.Errorf("tracing.enabled or otel.enabled is required for production-like environments"))
}
if cfg.Audit.Kind != "file" {
*errs = append(*errs, fmt.Errorf("audit.kind file is required for production-like environments"))
}
if sdkInsecureEnv() {
*errs = append(*errs, fmt.Errorf("LONGHENG_SDK_INSECURE must not be enabled for production-like environments"))
}
for idx, token := range cfg.Admin.Tokens {
rejectKnownSecret(errs, fmt.Sprintf("admin.tokens[%d].token", idx), token.Token)
}
if isGatewayService(cfg.Service.Name) {
// 多 gateway 必须共享在线状态,否则跨网关推送、踢人和重连恢复都会失真。
if cfg.Presence.Kind != "redis" {
*errs = append(*errs, fmt.Errorf("presence.kind redis is required for production gateway services"))
}
if !cfg.Gateway.ConnectionRateLimitEnabled {
*errs = append(*errs, fmt.Errorf("gateway.connection_rate_limit_enabled is required for production gateway services"))
}
if cfg.Gateway.AuthToken != "" {
rejectKnownSecret(errs, "gateway.auth_token", cfg.Gateway.AuthToken)
}
if cfg.Gateway.LoginToken != "" {
rejectKnownSecret(errs, "gateway.login_token", cfg.Gateway.LoginToken)
}
requireProdSecret(errs, "gateway.session_key_master_secret", cfg.Gateway.SessionKeyMasterSecret)
}
if cfg.Idempotency.Kind == "redis" || cfg.Idempotency.Kind == "mysql_redis" {
requireProdSecret(errs, "idempotency.redis_password", cfg.Idempotency.RedisPassword)
}
if cfg.Registry.Kind == "redis" {
requireProdSecret(errs, "registry.redis_password", cfg.Registry.RedisPassword)
}
if cfg.Bus.Kind == "redis" {
requireProdSecret(errs, "bus.redis_password", cfg.Bus.RedisPassword)
if !cfg.Bus.RedisTLSEnabled {
*errs = append(*errs, fmt.Errorf("bus.redis_tls_enabled is required for production-like redis bus"))
}
}
if cfg.Bus.Kind == "nats" {
if strings.TrimSpace(cfg.Bus.NATSToken) == "" && strings.TrimSpace(cfg.Bus.NATSCredentials) == "" &&
(strings.TrimSpace(cfg.Bus.NATSUsername) == "" || strings.TrimSpace(cfg.Bus.NATSPassword) == "") {
*errs = append(*errs, fmt.Errorf("bus nats requires token, credentials, or username/password for production-like environments"))
}
if strings.TrimSpace(cfg.Bus.NATSToken) != "" {
rejectKnownSecret(errs, "bus.nats_token", cfg.Bus.NATSToken)
}
if strings.TrimSpace(cfg.Bus.NATSPassword) != "" {
rejectKnownSecret(errs, "bus.nats_password", cfg.Bus.NATSPassword)
}
if !cfg.Bus.NATSTLSEnabled && strings.TrimSpace(cfg.Bus.NATSCAFile) == "" && strings.TrimSpace(cfg.Bus.NATSCertFile) == "" {
*errs = append(*errs, fmt.Errorf("bus.nats_tls_enabled or nats CA/client cert is required for production-like nats bus"))
}
}
if cfg.Cache.Kind == "redis" {
requireProdSecret(errs, "cache.redis_password", cfg.Cache.RedisPassword)
}
if cfg.Lock.Kind == "redis" {
requireProdSecret(errs, "lock.redis_password", cfg.Lock.RedisPassword)
}
if cfg.Presence.Kind == "redis" {
requireProdSecret(errs, "presence.redis_password", cfg.Presence.RedisPassword)
}
if cfg.ProfileStore.StoreKind == "redis" || cfg.ProfileStore.StoreKind == "pika" || cfg.ProfileStore.StoreKind == "profiledb" || cfg.ProfileStore.StoreKind == "profile_db" || cfg.ProfileStore.StoreKind == "mysql_redis" {
requireProdSecret(errs, "profile_store.store_password", cfg.ProfileStore.StorePassword)
}
if cfg.ProfileStore.SummaryStoreKind == "redis" || cfg.ProfileStore.SummaryStoreKind == "pika" {
requireProdSecret(errs, "profile_store.summary_store_password", cfg.ProfileStore.SummaryStorePassword)
}
if isProfileCriticalSvc(cfg.Service.Name) {
// logic/playerd 是 Profile 权威写路径,生产不能退回文件、内存或纯 Redis 存档。
switch cfg.ProfileStore.StoreKind {
case "mysql", "mysql_redis":
default:
*errs = append(*errs, fmt.Errorf("profile_store.store_kind mysql or mysql_redis is required for production %s services", cfg.Service.Name))
}
switch cfg.ProfileStore.SummaryStoreKind {
case "redis", "pika":
default:
*errs = append(*errs, fmt.Errorf("profile_store.summary_store_kind redis or pika is required for production %s services", cfg.Service.Name))
}
}
}
func sdkInsecureEnv() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("LONGHENG_SDK_INSECURE"))) {
case "1", "true", "yes", "y", "on":
return true
default:
return false
}
}
func validEnvPlaceholders(errs *[]error, value reflect.Value, path string) {
if !value.IsValid() {
return
}
if value.Kind() == reflect.Pointer {
if value.IsNil() {
return
}
validEnvPlaceholders(errs, value.Elem(), path)
return
}
switch value.Kind() {
case reflect.Struct:
valueType := value.Type()
for idx := 0; idx < value.NumField(); idx++ {
fieldType := valueType.Field(idx)
if fieldType.PkgPath != "" {
continue
}
fieldPath := joinConfigPath(path, tomlFieldName(fieldType))
validEnvPlaceholders(errs, value.Field(idx), fieldPath)
}
case reflect.Slice, reflect.Array:
for idx := 0; idx < value.Len(); idx++ {
validEnvPlaceholders(errs, value.Index(idx), fmt.Sprintf("%s[%d]", path, idx))
}
case reflect.String:
text := value.String()
if configEnvPattern.MatchString(text) || isShellEnvOnly(text) {
// 未解析占位符比空值更危险:看起来非空,实际会把字面量当密钥或地址启动。
*errs = append(*errs, fmt.Errorf("%s contains unresolved environment variable placeholder", path))
}
}
}
func tomlFieldName(field reflect.StructField) string {
tag := field.Tag.Get("toml")
if tag == "" || tag == "-" {
return strings.ToLower(field.Name)
}
name := strings.Split(tag, ",")[0]
if name == "" {
return strings.ToLower(field.Name)
}
return name
}
func joinConfigPath(prefix, name string) string {
if prefix == "" {
return name
}
return prefix + "." + name
}
func isShellEnvOnly(value string) bool {
value = strings.TrimSpace(value)
if len(value) < 2 || value[0] != '$' {
return false
}
for idx, r := range value[1:] {
if idx == 0 {
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
continue
}
return false
}
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
return false
}
return true
}
func requireProdSecret(errs *[]error, field, value string) {
requireString(errs, field, value)
if strings.TrimSpace(value) == "" {
return
}
// 非空还不够,常见本地默认值也要直接拒绝。
rejectKnownSecret(errs, field, value)
}
func rejectKnownSecret(errs *[]error, field, value string) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "local-admin-token",
"local-login-token",
"local-auth-token",
"local-session-key-master-secret",
"local-token",
"admin",
"token",
"password",
"redis",
"mysql",
"changeme",
"change-me",
"change_me":
*errs = append(*errs, fmt.Errorf("%s must not use a local/default secret in production-like environments", field))
}
}
func rejectProdDefault(errs *[]error, field, value string) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "local", "default", "changeme", "change-me", "change_me":
*errs = append(*errs, fmt.Errorf("%s must not use a local/default value in production-like environments", field))
}
}
func IsProdLikeEnv(environment string) bool {
return isProdLikeEnv(environment)
}
// UsesDistributedRuntimeAuthority 判断配置是否已经启用分布式或持久化权威路径,
// 用于承载有状态运行时决策。
//
// 它不会单独把整份配置判定为生产环境:本地开发也可能使用 MySQL/Redis。
// 该信号只服务于运行时装配和预检,避免调用方只依赖 service.environment。
func UsesDistributedRuntimeAuthority(cfg ServiceConfig) bool {
if cfg.Topology.Enabled {
return true
}
if isProfileCriticalSvc(cfg.Service.Name) && ProfileStoreUsesDurableAuthority(cfg.ProfileStore) {
return true
}
return false
}
// ProfileStoreUsesDurableAuthority 判断 profile_store.store_kind 是否由持久化 DB
// 作为权威存储,而不是进程本地或仅文件的开发存储。
func ProfileStoreUsesDurableAuthority(section ProfileStoreSection) bool {
switch strings.ToLower(strings.TrimSpace(section.StoreKind)) {
case "mysql", "mysql_redis":
return true
default:
return false
}
}
func isProdLikeEnv(environment string) bool {
environment = strings.ToLower(strings.TrimSpace(environment))
if environment == "" {
return false
}
for _, token := range []string{"prod", "production", "staging", "stage", "preprod", "pre-production", "k8s", "kubernetes", "helm", "cluster", "live"} {
if strings.Contains(environment, token) {
return true
}
}
return false
}
func isGatewayService(serviceName string) bool {
return strings.EqualFold(strings.TrimSpace(serviceName), "gateway")
}
func isEventLogCritical(serviceName string) bool {
switch strings.ToLower(strings.TrimSpace(serviceName)) {
case "gateway", "logic", "playerd", "payx":
return true
default:
return false
}
}
func isIdemCriticalSvc(serviceName string) bool {
switch strings.ToLower(strings.TrimSpace(serviceName)) {
case "logic", "playerd":
return true
default:
return false
}
}
func isProfileCriticalSvc(serviceName string) bool {
switch strings.ToLower(strings.TrimSpace(serviceName)) {
case "logic", "playerd":
return true
default:
return false
}
}
func isBattleAgentService(serviceName string) bool {
return strings.EqualFold(strings.TrimSpace(serviceName), "battleagent")
}
@@ -0,0 +1,118 @@
package config
import (
"fmt"
"strconv"
"strings"
)
func validateMySQLShards(errs *[]error, section ProfileStoreSection) {
if !section.MySQLShardingEnabled && len(section.MySQLShards) == 0 {
return
}
if len(section.MySQLShards) == 0 {
*errs = append(*errs, fmt.Errorf("profile_store.mysql_shards is required when mysql_sharding_enabled is true"))
return
}
seen := map[string]struct{}{}
useHashSlots := false
hashSlotOwners := make([]string, 1024)
for idx, shard := range section.MySQLShards {
field := fmt.Sprintf("profile_store.mysql_shards[%d]", idx)
requireSafeName(errs, field+".id", shard.ID)
if _, ok := seen[shard.ID]; ok {
*errs = append(*errs, fmt.Errorf("%s.id %q is duplicated", field, shard.ID))
}
seen[shard.ID] = struct{}{}
if strings.TrimSpace(shard.DSN) == "" && strings.TrimSpace(section.MySQLDSN) == "" {
*errs = append(*errs, fmt.Errorf("%s.dsn is required when profile_store.mysql_dsn is empty", field))
}
if strings.TrimSpace(shard.TableName) != "" {
requireSQLIdentifier(errs, field+".table_name", shard.TableName)
}
if strings.TrimSpace(shard.TablePrefix) != "" {
requireSQLIdentifier(errs, field+".table_prefix", shard.TablePrefix)
}
if strings.TrimSpace(shard.TableName) != "" && strings.TrimSpace(shard.TablePrefix) != "" {
*errs = append(*errs, fmt.Errorf("%s can set table_name or table_prefix, not both", field))
}
if strings.TrimSpace(shard.HashSlots) != "" {
useHashSlots = true
ranges, err := parseHashSlotRanges(shard.HashSlots)
if err != nil {
*errs = append(*errs, fmt.Errorf("%s.hash_slots %w", field, err))
} else {
for _, item := range ranges {
for slot := item.start; slot <= item.end; slot++ {
if hashSlotOwners[slot] != "" {
*errs = append(*errs, fmt.Errorf("%s.hash_slots overlaps slot %d with shard %q", field, slot, hashSlotOwners[slot]))
}
hashSlotOwners[slot] = shard.ID
}
}
}
}
if shard.MaxOpenConns < 0 {
*errs = append(*errs, fmt.Errorf("%s.max_open_conns must be non-negative", field))
}
if shard.MaxIdleConns < 0 {
*errs = append(*errs, fmt.Errorf("%s.max_idle_conns must be non-negative", field))
}
if shard.ConnMaxLifetime < 0 {
*errs = append(*errs, fmt.Errorf("%s.conn_max_lifetime_seconds must be non-negative", field))
}
}
if useHashSlots {
for idx, shard := range section.MySQLShards {
if strings.TrimSpace(shard.HashSlots) == "" {
*errs = append(*errs, fmt.Errorf("profile_store.mysql_shards[%d].hash_slots is required when any shard uses slot routing", idx))
}
}
for slot, owner := range hashSlotOwners {
if owner == "" {
*errs = append(*errs, fmt.Errorf("profile_store.mysql_shards hash slot %d is not assigned", slot))
break
}
}
}
}
type profileHashSlots struct {
start int
end int
}
func parseHashSlotRanges(value string) ([]profileHashSlots, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, fmt.Errorf("is required")
}
parts := strings.Split(value, ",")
out := make([]profileHashSlots, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
return nil, fmt.Errorf("contains empty range")
}
bounds := strings.Split(part, "-")
if len(bounds) > 2 {
return nil, fmt.Errorf("range %q is invalid", part)
}
start, err := strconv.Atoi(strings.TrimSpace(bounds[0]))
if err != nil {
return nil, fmt.Errorf("range %q has invalid start", part)
}
end := start
if len(bounds) == 2 {
end, err = strconv.Atoi(strings.TrimSpace(bounds[1]))
if err != nil {
return nil, fmt.Errorf("range %q has invalid end", part)
}
}
if start < 0 || end < start || end >= 1024 {
return nil, fmt.Errorf("range %q must be within 0-1023", part)
}
out = append(out, profileHashSlots{start: start, end: end})
}
return out, nil
}
@@ -0,0 +1,57 @@
package config
import (
"context"
"errors"
"strings"
"time"
)
var (
ErrConfigProviderRequired = errors.New("config provider is required")
ErrConfigKeyRequired = errors.New("config key is required")
ErrConfigSnapshotNotFound = errors.New("config snapshot not found")
)
type Snapshot struct {
Key string `json:"key"`
Data []byte `json:"data,omitempty"`
Version string `json:"version,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
type Provider interface {
LoadSnapshot(context.Context, string) (Snapshot, bool, error)
}
type WatchProvider interface {
Provider
WatchSnapshots(context.Context, string) (<-chan Snapshot, <-chan error)
}
type ProviderFunc func(context.Context, string) (Snapshot, bool, error)
func (f ProviderFunc) LoadSnapshot(ctx context.Context, key string) (Snapshot, bool, error) {
if f == nil {
return Snapshot{}, false, ErrConfigProviderRequired
}
return f(ctx, key)
}
func LoadRequiredSnapshot(ctx context.Context, provider Provider, key string) (Snapshot, error) {
if provider == nil {
return Snapshot{}, ErrConfigProviderRequired
}
if strings.TrimSpace(key) == "" {
return Snapshot{}, ErrConfigKeyRequired
}
snapshot, ok, err := provider.LoadSnapshot(ctx, key)
if err != nil {
return Snapshot{}, err
}
if !ok {
return Snapshot{}, ErrConfigSnapshotNotFound
}
return snapshot, nil
}
@@ -0,0 +1,95 @@
package config
func (c ServiceConfig) Redacted() ServiceConfig {
redacted := c
if redacted.Admin.Token != "" {
redacted.Admin.Token = "[redacted]"
}
if len(redacted.Admin.Tokens) > 0 {
redacted.Admin.Tokens = append([]AdminTokenSection(nil), redacted.Admin.Tokens...)
for idx := range redacted.Admin.Tokens {
if len(redacted.Admin.Tokens[idx].Scopes) > 0 {
redacted.Admin.Tokens[idx].Scopes = append([]string(nil), redacted.Admin.Tokens[idx].Scopes...)
}
if redacted.Admin.Tokens[idx].Token != "" {
redacted.Admin.Tokens[idx].Token = "[redacted]"
}
}
}
if redacted.Gateway.AuthToken != "" {
redacted.Gateway.AuthToken = "[redacted]"
}
if redacted.Gateway.LoginToken != "" {
redacted.Gateway.LoginToken = "[redacted]"
}
if redacted.Gateway.SessionKeyMasterSecret != "" {
redacted.Gateway.SessionKeyMasterSecret = "[redacted]"
}
if redacted.ProfileStore.StorePassword != "" {
redacted.ProfileStore.StorePassword = "[redacted]"
}
if redacted.ProfileStore.SummaryStorePassword != "" {
redacted.ProfileStore.SummaryStorePassword = "[redacted]"
}
if redacted.ProfileStore.MySQLDSN != "" {
redacted.ProfileStore.MySQLDSN = "[redacted]"
}
if len(redacted.ProfileStore.MySQLShards) > 0 {
redacted.ProfileStore.MySQLShards = append([]ProfileMySQLShard(nil), redacted.ProfileStore.MySQLShards...)
for idx := range redacted.ProfileStore.MySQLShards {
if redacted.ProfileStore.MySQLShards[idx].DSN != "" {
redacted.ProfileStore.MySQLShards[idx].DSN = "[redacted]"
}
}
}
if redacted.Idempotency.RedisPassword != "" {
redacted.Idempotency.RedisPassword = "[redacted]"
}
if redacted.Idempotency.MySQLDSN != "" {
redacted.Idempotency.MySQLDSN = "[redacted]"
}
if redacted.Registry.RedisPassword != "" {
redacted.Registry.RedisPassword = "[redacted]"
}
if redacted.Bus.RedisPassword != "" {
redacted.Bus.RedisPassword = "[redacted]"
}
if redacted.Bus.NATSToken != "" {
redacted.Bus.NATSToken = "[redacted]"
}
if redacted.Bus.NATSPassword != "" {
redacted.Bus.NATSPassword = "[redacted]"
}
if redacted.Bus.NATSCredentials != "" {
redacted.Bus.NATSCredentials = "[redacted]"
}
if redacted.EventLog.MySQLDSN != "" {
redacted.EventLog.MySQLDSN = "[redacted]"
}
if redacted.AccountCenter.MySQLDSN != "" {
redacted.AccountCenter.MySQLDSN = "[redacted]"
}
if redacted.EventLog.PublishHTTPURL != "" {
redacted.EventLog.PublishHTTPURL = "[redacted]"
}
if len(redacted.EventLog.PublishHTTPHeaders) > 0 {
headers := make(map[string]string, len(redacted.EventLog.PublishHTTPHeaders))
for key := range redacted.EventLog.PublishHTTPHeaders {
headers[key] = "[redacted]"
}
redacted.EventLog.PublishHTTPHeaders = headers
}
if redacted.Cache.RedisPassword != "" {
redacted.Cache.RedisPassword = "[redacted]"
}
if redacted.Lock.RedisPassword != "" {
redacted.Lock.RedisPassword = "[redacted]"
}
if redacted.Presence.RedisPassword != "" {
redacted.Presence.RedisPassword = "[redacted]"
}
if redacted.StorageObject.MySQLDSN != "" {
redacted.StorageObject.MySQLDSN = "[redacted]"
}
return redacted
}
+485
View File
@@ -0,0 +1,485 @@
package config
type ServiceConfig struct {
Service ServiceSection `toml:"service"`
Cluster ClusterSection `toml:"cluster"`
Admin AdminSection `toml:"admin"`
Metrics MetricsSection `toml:"metrics"`
Tracing TracingSection `toml:"tracing"`
OTel OTelSection `toml:"otel"`
Debug DebugSection `toml:"debug"`
DataTables DataTablesSection `toml:"data_tables"`
I18N I18NSection `toml:"i18n"`
PVF PVFSection `toml:"pvf"`
Audit AuditSection `toml:"audit"`
LogicLog LogicLogSection `toml:"logic_log"`
BILog BILogSection `toml:"bi_log"`
EventLog EventLogSection `toml:"eventlog"`
AccountCenter AccountCenterSection `toml:"account_center"`
StorageObject StorageObjectSection `toml:"storage_object"`
BattleAgent BattleAgentSection `toml:"battle_agent"`
Reload ReloadSection `toml:"reload"`
HotUpdate HotUpdateSection `toml:"hot_update"`
StateSync StateSyncSection `toml:"state_sync"`
ServerGroup ServerGroupSection `toml:"server_group"`
Topology TopologySection `toml:"topology"`
Discovery DiscoverySection `toml:"discovery"`
RPC RPCSection `toml:"rpc"`
RateLimit RateLimitSection `toml:"rate_limit"`
Idempotency IdempotencySection `toml:"idempotency"`
Registry RegistrySection `toml:"registry"`
Bus BusSection `toml:"bus"`
Cache CacheSection `toml:"cache"`
Lock LockSection `toml:"lock"`
Presence PresenceSection `toml:"presence"`
Worker WorkerSection `toml:"worker"`
ProfileStore ProfileStoreSection `toml:"profile_store"`
Gateway GatewaySection `toml:"gateway"`
}
type ServiceSection struct {
Name string `toml:"name"`
NodeID string `toml:"node_id"`
Environment string `toml:"environment"`
PublicAddr string `toml:"public_addr"`
PrivateAddr string `toml:"private_addr"`
StopTimeoutSeconds int `toml:"stop_timeout_seconds"`
StopInitialGraceSeconds int `toml:"stop_initial_grace_seconds"`
StopProbeSeconds int `toml:"stop_probe_seconds"`
StopStableSeconds int `toml:"stop_stable_seconds"`
}
type ClusterSection struct {
ID string `toml:"id"`
GID int64 `toml:"gid"`
VirtualGID int64 `toml:"virtual_gid"`
ShardID int64 `toml:"shard_id"`
Zone string `toml:"zone"`
Version string `toml:"version"`
DataVersion string `toml:"data_version"`
Maintaining bool `toml:"maintaining"`
HeartbeatIntervalSeconds int `toml:"heartbeat_interval_seconds"`
}
type AdminSection struct {
Listen string `toml:"listen"`
Token string `toml:"token"`
RBACEnabled bool `toml:"rbac_enabled"`
DangerousConfirm bool `toml:"dangerous_confirm"`
ReadTimeoutSeconds int `toml:"read_timeout_seconds"`
ReadHeaderTimeoutSeconds int `toml:"read_header_timeout_seconds"`
WriteTimeoutSeconds int `toml:"write_timeout_seconds"`
IdleTimeoutSeconds int `toml:"idle_timeout_seconds"`
Tokens []AdminTokenSection `toml:"tokens"`
}
type AdminTokenSection struct {
Name string `toml:"name"`
Token string `toml:"token"`
Role string `toml:"role"`
Scopes []string `toml:"scopes"`
}
type MetricsSection struct {
PrometheusEnabled bool `toml:"prometheus_enabled"`
RequireAdminToken bool `toml:"require_admin_token"`
}
type TracingSection struct {
Enabled bool `toml:"enabled"`
MaxSpans int `toml:"max_spans"`
}
type OTelSection struct {
Enabled bool `toml:"enabled"`
Endpoint string `toml:"endpoint"`
Insecure bool `toml:"insecure"`
ServiceName string `toml:"service_name"`
SampleRatio float64 `toml:"sample_ratio"`
BatchTimeoutSeconds int `toml:"batch_timeout_seconds"`
}
type DebugSection struct {
PprofEnabled bool `toml:"pprof_enabled"`
}
type DataTablesSection struct {
Enabled bool `toml:"enabled"`
Directory string `toml:"directory"`
Version string `toml:"version"`
}
type I18NSection struct {
Enabled bool `toml:"enabled"`
Directory string `toml:"directory"`
DefaultLanguage string `toml:"default_language"`
Version string `toml:"version"`
}
type AuditSection struct {
Enabled bool `toml:"enabled"`
Kind string `toml:"kind"`
FilePath string `toml:"file_path"`
MemoryLimit int `toml:"memory_limit"`
}
type LogicLogSection struct {
Enabled bool `toml:"enabled"`
Kind string `toml:"kind"`
FilePath string `toml:"file_path"`
ReasonsPath string `toml:"reasons_path"`
MemoryLimit int `toml:"memory_limit"`
}
type BILogSection struct {
Enabled bool `toml:"enabled"`
Kind string `toml:"kind"`
FilePath string `toml:"file_path"`
SchemaPath string `toml:"schema_path"`
MemoryLimit int `toml:"memory_limit"`
}
type EventLogSection struct {
Enabled bool `toml:"enabled"`
StoreKind string `toml:"store_kind"`
MySQLDSN string `toml:"mysql_dsn"`
MySQLTable string `toml:"mysql_table"`
MySQLMaxOpenConns int `toml:"mysql_max_open_conns"`
MySQLMaxIdleConns int `toml:"mysql_max_idle_conns"`
MySQLConnMaxLifetime int `toml:"mysql_conn_max_lifetime_seconds"`
MySQLEnsureSchema bool `toml:"mysql_ensure_schema"`
AdminPrefix string `toml:"admin_prefix"`
Strict bool `toml:"strict"`
PublishEnabled bool `toml:"publish_enabled"`
PublishIntervalSeconds int `toml:"publish_interval_seconds"`
PublishBatchSize int `toml:"publish_batch_size"`
PublishRetrySeconds int `toml:"publish_retry_seconds"`
PublishMaxAttempts int `toml:"publish_max_attempts"`
PublishKind string `toml:"publish_kind"`
PublishHTTPURL string `toml:"publish_http_url"`
PublishHTTPMethod string `toml:"publish_http_method"`
PublishHTTPTimeout int `toml:"publish_http_timeout_seconds"`
PublishHTTPPayload string `toml:"publish_http_payload"`
PublishHTTPHeaders map[string]string `toml:"publish_http_headers"`
}
type AccountCenterSection struct {
Enabled bool `toml:"enabled"`
StoreKind string `toml:"store_kind"`
MySQLDSN string `toml:"mysql_dsn"`
MySQLTable string `toml:"mysql_table"`
MySQLEnsureSchema bool `toml:"mysql_ensure_schema"`
AdminPrefix string `toml:"admin_prefix"`
}
type StorageObjectSection struct {
Enabled bool `toml:"enabled"`
StoreKind string `toml:"store_kind"`
MySQLDSN string `toml:"mysql_dsn"`
MySQLTable string `toml:"mysql_table"`
MySQLEnsureSchema bool `toml:"mysql_ensure_schema"`
AdminPrefix string `toml:"admin_prefix"`
}
type BattleAgentSection struct {
SettlementOutboxKind string `toml:"settlement_outbox_kind"`
SettlementOutboxStream string `toml:"settlement_outbox_stream"`
SettlementOutboxType string `toml:"settlement_outbox_type"`
}
type ReloadSection struct {
Enabled bool `toml:"enabled"`
PollIntervalSeconds int `toml:"poll_interval_seconds"`
ApplyTimeoutSeconds int `toml:"apply_timeout_seconds"`
}
type HotUpdateSection struct {
Enabled bool `toml:"enabled"`
Target string `toml:"target"`
ControlKind string `toml:"control_kind"`
SourceKind string `toml:"source_kind"`
ApplierKind string `toml:"applier_kind"`
Workspace string `toml:"workspace"`
ApplyTimeoutSeconds int `toml:"apply_timeout_seconds"`
SigningKeyEnv string `toml:"signing_key_env"`
RequireSignature bool `toml:"require_signature"`
NativePatchAllowedTargets []string `toml:"native_patch_allowed_targets"`
PatchOldSymbols []string `toml:"native_patch_allowed_old_symbols"`
NativePatchRequireBuildID bool `toml:"native_patch_require_build_id"`
PatchRequireActor bool `toml:"native_patch_require_requested_by"`
NativePatchRequireReason bool `toml:"native_patch_require_reason"`
NativePatchMinReasonLength int `toml:"native_patch_min_reason_length"`
NativePatchMaxSymbols int `toml:"native_patch_max_symbols"`
NativePatchMaxLiveSeconds int `toml:"native_patch_max_live_seconds"`
}
type StateSyncSection struct {
Enabled bool `toml:"enabled"`
Kind string `toml:"kind"`
Namespace string `toml:"namespace"`
TTLSeconds int `toml:"ttl_seconds"`
CleanupIntervalSeconds int `toml:"cleanup_interval_seconds"`
MaxPayloadBytes int `toml:"max_payload_bytes"`
}
type ServerGroupSection struct {
Enabled bool `toml:"enabled"`
StoreKind string `toml:"store_kind"`
PlanFile string `toml:"plan_file"`
MergeArchiveDir string `toml:"merge_archive_dir"`
AdminPrefix string `toml:"admin_prefix"`
InZoneDelaySeconds int `toml:"in_zone_delay_seconds"`
NoticeLeadSeconds int `toml:"notice_lead_seconds"`
}
type TopologySection struct {
Enabled bool `toml:"enabled"`
Services []string `toml:"services"`
GatewayService string `toml:"gateway_service"`
GatewayBackendService string `toml:"gateway_backend_service"`
RejectMaintaining bool `toml:"reject_maintaining"`
RefreshIntervalSeconds int `toml:"refresh_interval_seconds"`
}
type DiscoverySection struct {
CacheTTLSeconds int `toml:"cache_ttl_seconds"`
FailureTTLSeconds int `toml:"failure_ttl_seconds"`
FailureThreshold int `toml:"failure_threshold"`
AllowMaintaining bool `toml:"allow_maintaining"`
Strategy string `toml:"strategy"`
}
type RPCSection struct {
CallTimeoutSeconds int `toml:"call_timeout_seconds"`
MaxPending int `toml:"max_pending"`
MaxPayloadBytes int `toml:"max_payload_bytes"`
}
type RateLimitSection struct {
Enabled bool `toml:"enabled"`
Algorithm string `toml:"algorithm"`
WindowSeconds int `toml:"window_seconds"`
MaxRequests int `toml:"max_requests"`
Rules string `toml:"rules"`
CleanupIntervalSeconds int `toml:"cleanup_interval_seconds"`
TrustedHeader string `toml:"trusted_header"`
}
type IdempotencySection struct {
Kind string `toml:"kind"`
TTLSeconds int `toml:"ttl_seconds"`
KeyPrefix string `toml:"key_prefix"`
RedisAddress string `toml:"redis_address"`
RedisPassword string `toml:"redis_password"`
RedisDB int `toml:"redis_db"`
RedisPoolSize int `toml:"redis_pool_size"`
RedisTimeoutSeconds int `toml:"redis_timeout_seconds"`
RedisConnectTimeoutSecs int `toml:"redis_connect_timeout_seconds"`
RedisReadTimeoutSecs int `toml:"redis_read_timeout_seconds"`
RedisWriteTimeoutSecs int `toml:"redis_write_timeout_seconds"`
MySQLDSN string `toml:"mysql_dsn"`
MySQLMaxOpenConns int `toml:"mysql_max_open_conns"`
MySQLMaxIdleConns int `toml:"mysql_max_idle_conns"`
MySQLConnMaxLifetimeSec int `toml:"mysql_conn_max_lifetime_seconds"`
}
type RegistrySection struct {
Kind string `toml:"kind"`
Namespace string `toml:"namespace"`
Endpoints []string `toml:"endpoints"`
LeaseTTL int64 `toml:"lease_ttl"`
RedisPassword string `toml:"redis_password"`
RedisDB int `toml:"redis_db"`
}
type BusSection struct {
Kind string `toml:"kind"`
Namespace string `toml:"namespace"`
Endpoints []string `toml:"endpoints"`
RedisUsername string `toml:"redis_username"`
RedisPassword string `toml:"redis_password"`
RedisDB int `toml:"redis_db"`
RedisTLSEnabled bool `toml:"redis_tls_enabled"`
RedisTLSServerName string `toml:"redis_tls_server_name"`
NATSToken string `toml:"nats_token"`
NATSUsername string `toml:"nats_username"`
NATSPassword string `toml:"nats_password"`
NATSCredentials string `toml:"nats_credentials"`
NATSTLSEnabled bool `toml:"nats_tls_enabled"`
NATSTLSServerName string `toml:"nats_tls_server_name"`
NATSCAFile string `toml:"nats_ca_file"`
NATSCertFile string `toml:"nats_cert_file"`
NATSKeyFile string `toml:"nats_key_file"`
NATSName string `toml:"nats_name"`
NATSTimeoutSeconds int `toml:"nats_timeout_seconds"`
NATSPingIntervalSeconds int `toml:"nats_ping_interval_seconds"`
NATSMaxPingsOutstanding int `toml:"nats_max_pings_outstanding"`
NATSMaxReconnects int `toml:"nats_max_reconnects"`
NATSReconnectWaitSeconds int `toml:"nats_reconnect_wait_seconds"`
NATSWireEncoding string `toml:"nats_wire_encoding"`
}
type CacheSection struct {
Kind string `toml:"kind"`
KeyPrefix string `toml:"key_prefix"`
DefaultTTLSeconds int `toml:"default_ttl_seconds"`
MaxEntries int `toml:"max_entries"`
RedisAddress string `toml:"redis_address"`
RedisPassword string `toml:"redis_password"`
RedisDB int `toml:"redis_db"`
RedisPoolSize int `toml:"redis_pool_size"`
RedisTimeoutSeconds int `toml:"redis_timeout_seconds"`
RedisConnectTimeout int `toml:"redis_connect_timeout_seconds"`
RedisReadTimeout int `toml:"redis_read_timeout_seconds"`
RedisWriteTimeout int `toml:"redis_write_timeout_seconds"`
}
type LockSection struct {
Kind string `toml:"kind"`
KeyPrefix string `toml:"key_prefix"`
TTLSeconds int `toml:"ttl_seconds"`
RedisAddress string `toml:"redis_address"`
RedisPassword string `toml:"redis_password"`
RedisDB int `toml:"redis_db"`
RedisPoolSize int `toml:"redis_pool_size"`
RedisTimeoutSeconds int `toml:"redis_timeout_seconds"`
RedisConnectTimeout int `toml:"redis_connect_timeout_seconds"`
RedisReadTimeout int `toml:"redis_read_timeout_seconds"`
RedisWriteTimeout int `toml:"redis_write_timeout_seconds"`
}
type PresenceSection struct {
Kind string `toml:"kind"`
KeyPrefix string `toml:"key_prefix"`
TTLSeconds int `toml:"ttl_seconds"`
RedisAddress string `toml:"redis_address"`
RedisPassword string `toml:"redis_password"`
RedisDB int `toml:"redis_db"`
RedisPoolSize int `toml:"redis_pool_size"`
RedisTimeoutSeconds int `toml:"redis_timeout_seconds"`
RedisConnectTimeout int `toml:"redis_connect_timeout_seconds"`
RedisReadTimeout int `toml:"redis_read_timeout_seconds"`
RedisWriteTimeout int `toml:"redis_write_timeout_seconds"`
}
type WorkerSection struct {
Size int `toml:"size"`
Queue int `toml:"queue"`
}
type ProfileStoreSection struct {
StoreKind string `toml:"store_kind"`
StoreDirectory string `toml:"store_directory"`
StoreAddress string `toml:"store_address"`
StorePassword string `toml:"store_password"`
StoreDB int `toml:"store_db"`
StoreKeyPrefix string `toml:"store_key_prefix"`
StorePoolSize int `toml:"store_pool_size"`
StoreTimeoutSeconds int `toml:"store_timeout_seconds"`
StoreConnectTimeout int `toml:"store_connect_timeout_seconds"`
StoreReadTimeout int `toml:"store_read_timeout_seconds"`
StoreWriteTimeout int `toml:"store_write_timeout_seconds"`
StoreTTLSeconds int `toml:"store_ttl_seconds"`
MySQLDSN string `toml:"mysql_dsn"`
MySQLMaxOpenConns int `toml:"mysql_max_open_conns"`
MySQLMaxIdleConns int `toml:"mysql_max_idle_conns"`
MySQLConnMaxLifetime int `toml:"mysql_conn_max_lifetime_seconds"`
MySQLShardingEnabled bool `toml:"mysql_sharding_enabled"`
MySQLShards []ProfileMySQLShard `toml:"mysql_shards"`
SummaryStoreKind string `toml:"summary_store_kind"`
SummaryStoreAddress string `toml:"summary_store_address"`
SummaryStorePassword string `toml:"summary_store_password"`
SummaryStoreDB int `toml:"summary_store_db"`
SummaryStoreKeyPrefix string `toml:"summary_store_key_prefix"`
SummaryStorePoolSize int `toml:"summary_store_pool_size"`
SummaryStoreTimeout int `toml:"summary_store_timeout_seconds"`
SummaryStoreConnectTimeout int `toml:"summary_store_connect_timeout_seconds"`
SummaryStoreReadTimeout int `toml:"summary_store_read_timeout_seconds"`
SummaryStoreWriteTimeout int `toml:"summary_store_write_timeout_seconds"`
SummaryStoreTTL int `toml:"summary_store_ttl_seconds"`
SaveMode string `toml:"save_mode"`
AsyncFlushIntervalSeconds int `toml:"async_flush_interval_seconds"`
AsyncMaxPending int `toml:"async_max_pending"`
AsyncRetryBackoffSeconds int `toml:"async_retry_backoff_seconds"`
AsyncMaxRetries int `toml:"async_max_retries"`
AsyncAutoExpireSeconds int `toml:"async_auto_expire_seconds"`
AsyncDeadLetterLimit int `toml:"async_dead_letter_limit"`
AsyncDeadLetterDirectory string `toml:"async_dead_letter_directory"`
}
type ProfileMySQLShard struct {
ID string `toml:"id"`
DSN string `toml:"dsn"`
TableName string `toml:"table_name"`
TablePrefix string `toml:"table_prefix"`
HashSlots string `toml:"hash_slots"`
MaxOpenConns int `toml:"max_open_conns"`
MaxIdleConns int `toml:"max_idle_conns"`
ConnMaxLifetime int `toml:"conn_max_lifetime_seconds"`
}
type GatewaySection struct {
AuthListen string `toml:"auth_listen"`
AuthMode string `toml:"auth_mode"`
SharedLoginTokens bool `toml:"shared_login_tokens"`
RegistryGateSelection bool `toml:"registry_gate_selection"`
RemoteGateSelection bool `toml:"remote_gate_selection"`
GateRouteFeature string `toml:"gate_route_feature"`
GateService string `toml:"gate_service"`
TCPListen string `toml:"tcp_listen"`
TCPProxyProtocolEnabled bool `toml:"tcp_proxy_protocol_enabled"`
TCPProxyTrustedCIDRs []string `toml:"tcp_proxy_trusted_cidrs"`
TCPProxyHeaderSec int `toml:"tcp_proxy_header_timeout_seconds"`
KCPEnabled bool `toml:"kcp_enabled"`
KCPListen string `toml:"kcp_listen"`
KCPNoDelay int `toml:"kcp_nodelay"`
KCPInterval int `toml:"kcp_interval"`
KCPResend int `toml:"kcp_resend"`
KCPNoCongestion int `toml:"kcp_no_congestion"`
KCPWindowSize int `toml:"kcp_window_size"`
KCPDataShards int `toml:"kcp_data_shards"`
KCPParityShards int `toml:"kcp_parity_shards"`
QUICEnabled bool `toml:"quic_enabled"`
QUICListen string `toml:"quic_listen"`
QUICCertFile string `toml:"quic_cert_file"`
QUICKeyFile string `toml:"quic_key_file"`
QUICHandshakeSec int `toml:"quic_handshake_timeout_seconds"`
QUICMaxIdleTimeoutSeconds int `toml:"quic_max_idle_timeout_seconds"`
QUICKeepAliveSeconds int `toml:"quic_keep_alive_seconds"`
QUICMaxIncomingStreams int64 `toml:"quic_max_incoming_streams"`
WebSocketEnabled bool `toml:"websocket_enabled"`
WebSocketListen string `toml:"websocket_listen"`
WebSocketPath string `toml:"websocket_path"`
WebSocketMaxPayloadBytes int `toml:"websocket_max_payload_bytes"`
WebSocketHeartbeatSeconds int `toml:"websocket_heartbeat_seconds"`
ChatListen string `toml:"chat_listen"`
BackendService string `toml:"backend_service"`
BackendPollIntervalSeconds int `toml:"backend_poll_interval_seconds"`
RejectMaintainingBackends bool `toml:"reject_maintaining_backends"`
MaxConnections int `toml:"max_connections"`
ConnectionRateLimitEnabled bool `toml:"connection_rate_limit_enabled"`
ConnRateWindowSec int `toml:"connection_rate_limit_window_seconds"`
ConnectionRateLimitMax int `toml:"connection_rate_limit_max"`
ConnRateMaxEntries int `toml:"connection_rate_limit_max_entries"`
ConnRateIPv4Prefix int `toml:"connection_rate_limit_ipv4_prefix"`
ConnRateIPv6Prefix int `toml:"connection_rate_limit_ipv6_prefix"`
SessionIdleTimeoutSeconds int `toml:"session_idle_timeout_seconds"`
SessionSweepSec int `toml:"session_sweep_interval_seconds"`
SessionHookTimeoutSeconds int `toml:"session_hook_timeout_seconds"`
SessionKeyMasterSecret string `toml:"session_key_master_secret"`
AccountID string `toml:"account_id"`
AccountNumID int32 `toml:"account_num_id"`
AuthToken string `toml:"auth_token"`
LoginToken string `toml:"login_token"`
ServerVersion string `toml:"server_version"`
ShardID int32 `toml:"shard_id"`
ShardName string `toml:"shard_name"`
ShardDisplay string `toml:"shard_display"`
ShardState int32 `toml:"shard_state"`
Recommend int32 `toml:"recommend"`
RecommendLang string `toml:"recommend_lang"`
VirtualGID int32 `toml:"virtual_gid"`
ProductIDs []string `toml:"product_ids"`
HasRole bool `toml:"has_role"`
SendCreatePush bool `toml:"send_create_profile_push"`
}
@@ -0,0 +1,538 @@
package config
import (
"errors"
"fmt"
"net"
"strings"
)
func validateAdminTokens(errs *[]error, admin AdminSection) {
if strings.ContainsAny(admin.Token, "\r\n") {
*errs = append(*errs, fmt.Errorf("admin.token cannot contain line breaks"))
}
seen := map[string]string{}
if admin.Token != "" {
seen[admin.Token] = "admin.token"
}
for idx, token := range admin.Tokens {
prefix := fmt.Sprintf("admin.tokens[%d]", idx)
requireString(errs, prefix+".name", token.Name)
requireString(errs, prefix+".token", token.Token)
requireOneOf(errs, prefix+".role", token.Role, "admin", "operator", "viewer", "auditor")
if strings.ContainsAny(token.Token, "\r\n") {
*errs = append(*errs, fmt.Errorf("%s.token cannot contain line breaks", prefix))
}
if token.Token != "" {
if first, ok := seen[token.Token]; ok {
*errs = append(*errs, fmt.Errorf("%s.token duplicates %s", prefix, first))
} else {
seen[token.Token] = prefix + ".token"
}
}
for scopeIdx, scope := range token.Scopes {
if scope == "" {
*errs = append(*errs, fmt.Errorf("%s.scopes[%d] is required", prefix, scopeIdx))
continue
}
for _, r := range scope {
if isAdminScopeRune(r) {
continue
}
*errs = append(*errs, fmt.Errorf("%s.scopes[%d] %q can only contain letters, digits, dot, dash, underscore, colon, or star", prefix, scopeIdx, scope))
break
}
}
}
}
func isAdminScopeRune(r rune) bool {
return r == '.' || r == '-' || r == '_' || r == ':' || r == '*' ||
(r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9')
}
func (c ServiceConfig) Validate() error {
var errs []error
productionLike := isProdLikeEnv(c.Service.Environment)
requireSafeName(&errs, "service.name", c.Service.Name)
requireSafeName(&errs, "service.node_id", c.Service.NodeID)
requireString(&errs, "service.environment", c.Service.Environment)
requirePositiveInt(&errs, "service.stop_timeout_seconds", c.Service.StopTimeoutSeconds)
requireNonNegInt(&errs, "service.stop_initial_grace_seconds", c.Service.StopInitialGraceSeconds)
requirePositiveInt(&errs, "service.stop_probe_seconds", c.Service.StopProbeSeconds)
requirePositiveInt(&errs, "service.stop_stable_seconds", c.Service.StopStableSeconds)
if productionLike {
validateProdSafety(&errs, c)
}
requireSafeName(&errs, "cluster.id", c.Cluster.ID)
requireNonNegInt64(&errs, "cluster.gid", c.Cluster.GID)
requireNonNegInt64(&errs, "cluster.virtual_gid", c.Cluster.VirtualGID)
requireNonNegInt64(&errs, "cluster.shard_id", c.Cluster.ShardID)
requireString(&errs, "cluster.zone", c.Cluster.Zone)
requireString(&errs, "cluster.version", c.Cluster.Version)
requireString(&errs, "cluster.data_version", c.Cluster.DataVersion)
requirePositiveInt(&errs, "cluster.heartbeat_interval_seconds", c.Cluster.HeartbeatIntervalSeconds)
requireListenAddress(&errs, "admin.listen", c.Admin.Listen)
requireString(&errs, "admin.token", c.Admin.Token)
requirePositiveInt(&errs, "admin.read_timeout_seconds", c.Admin.ReadTimeoutSeconds)
requirePositiveInt(&errs, "admin.read_header_timeout_seconds", c.Admin.ReadHeaderTimeoutSeconds)
requirePositiveInt(&errs, "admin.write_timeout_seconds", c.Admin.WriteTimeoutSeconds)
requirePositiveInt(&errs, "admin.idle_timeout_seconds", c.Admin.IdleTimeoutSeconds)
if productionLike {
if !c.Admin.RBACEnabled {
errs = append(errs, fmt.Errorf("admin.rbac_enabled is required for production-like environments"))
}
if !c.Admin.DangerousConfirm {
errs = append(errs, fmt.Errorf("admin.dangerous_confirm is required for production-like environments"))
}
if !c.Audit.Enabled {
errs = append(errs, fmt.Errorf("audit.enabled is required for production-like environments"))
}
}
validateAdminTokens(&errs, c.Admin)
if c.RateLimit.Enabled {
if c.RateLimit.Algorithm != "window" && c.RateLimit.Algorithm != "token_bucket" {
errs = append(errs, fmt.Errorf("rate_limit.algorithm must be window or token_bucket"))
}
requirePositiveInt(&errs, "rate_limit.window_seconds", c.RateLimit.WindowSeconds)
requirePositiveInt(&errs, "rate_limit.max_requests", c.RateLimit.MaxRequests)
requirePositiveInt(&errs, "rate_limit.cleanup_interval_seconds", c.RateLimit.CleanupIntervalSeconds)
if c.RateLimit.TrustedHeader != "" && strings.ContainsAny(c.RateLimit.TrustedHeader, "\r\n") {
errs = append(errs, fmt.Errorf("rate_limit.trusted_header cannot contain line breaks"))
}
if err := validRateRules(c.RateLimit.Rules); err != nil {
errs = append(errs, err)
}
}
requireNonNegInt(&errs, "tracing.max_spans", c.Tracing.MaxSpans)
if c.OTel.Enabled {
requireString(&errs, "otel.endpoint", c.OTel.Endpoint)
requireString(&errs, "otel.service_name", c.OTel.ServiceName)
requirePositiveInt(&errs, "otel.batch_timeout_seconds", c.OTel.BatchTimeoutSeconds)
if c.OTel.SampleRatio <= 0 || c.OTel.SampleRatio > 1 {
errs = append(errs, fmt.Errorf("otel.sample_ratio must be greater than 0 and at most 1"))
}
}
if c.DataTables.Enabled {
requireString(&errs, "data_tables.directory", c.DataTables.Directory)
requireString(&errs, "data_tables.version", c.DataTables.Version)
}
if c.I18N.Enabled {
requireString(&errs, "i18n.directory", c.I18N.Directory)
requireString(&errs, "i18n.default_language", c.I18N.DefaultLanguage)
requireString(&errs, "i18n.version", c.I18N.Version)
}
c.validatePVF(&errs)
if c.Audit.Enabled {
requireOneOf(&errs, "audit.kind", c.Audit.Kind, "memory", "file")
requirePositiveInt(&errs, "audit.memory_limit", c.Audit.MemoryLimit)
if c.Audit.Kind == "file" {
requireString(&errs, "audit.file_path", c.Audit.FilePath)
}
}
if c.LogicLog.Enabled {
requireOneOf(&errs, "logic_log.kind", c.LogicLog.Kind, "memory", "file")
requirePositiveInt(&errs, "logic_log.memory_limit", c.LogicLog.MemoryLimit)
requireString(&errs, "logic_log.reasons_path", c.LogicLog.ReasonsPath)
if c.LogicLog.Kind == "file" {
requireString(&errs, "logic_log.file_path", c.LogicLog.FilePath)
}
if productionLike && c.LogicLog.Kind == "memory" {
errs = append(errs, fmt.Errorf("logic_log.kind memory is only allowed for local/dev/test environments"))
}
}
if c.BILog.Enabled {
requireOneOf(&errs, "bi_log.kind", c.BILog.Kind, "memory", "file")
requirePositiveInt(&errs, "bi_log.memory_limit", c.BILog.MemoryLimit)
requireString(&errs, "bi_log.schema_path", c.BILog.SchemaPath)
if c.BILog.Kind == "file" {
requireString(&errs, "bi_log.file_path", c.BILog.FilePath)
}
if productionLike && c.BILog.Kind == "memory" {
errs = append(errs, fmt.Errorf("bi_log.kind memory is only allowed for local/dev/test environments"))
}
}
if productionLike && isEventLogCritical(c.Service.Name) && !c.EventLog.Enabled {
errs = append(errs, fmt.Errorf("eventlog.enabled is required for production %s services", c.Service.Name))
}
if c.EventLog.Enabled {
requireOneOf(&errs, "eventlog.store_kind", c.EventLog.StoreKind, "memory", "mysql")
requireString(&errs, "eventlog.admin_prefix", c.EventLog.AdminPrefix)
if c.EventLog.StoreKind == "memory" && (c.EventLog.Strict || productionLike) {
errs = append(errs, fmt.Errorf("eventlog.store_kind memory is only allowed for local/dev/test eventlog paths"))
}
if c.EventLog.StoreKind == "mysql" {
requireString(&errs, "eventlog.mysql_dsn", c.EventLog.MySQLDSN)
requireSQLIdentifier(&errs, "eventlog.mysql_table", c.EventLog.MySQLTable)
requirePositiveInt(&errs, "eventlog.mysql_max_open_conns", c.EventLog.MySQLMaxOpenConns)
requirePositiveInt(&errs, "eventlog.mysql_max_idle_conns", c.EventLog.MySQLMaxIdleConns)
requirePositiveInt(&errs, "eventlog.mysql_conn_max_lifetime_seconds", c.EventLog.MySQLConnMaxLifetime)
}
if c.EventLog.PublishEnabled {
requireOneOf(&errs, "eventlog.publish_kind", c.EventLog.PublishKind, "bus", "http", "bus_http")
requirePositiveInt(&errs, "eventlog.publish_interval_seconds", c.EventLog.PublishIntervalSeconds)
requirePositiveInt(&errs, "eventlog.publish_batch_size", c.EventLog.PublishBatchSize)
requirePositiveInt(&errs, "eventlog.publish_retry_seconds", c.EventLog.PublishRetrySeconds)
requirePositiveInt(&errs, "eventlog.publish_max_attempts", c.EventLog.PublishMaxAttempts)
if c.EventLog.PublishKind == "http" || c.EventLog.PublishKind == "bus_http" {
requireString(&errs, "eventlog.publish_http_url", c.EventLog.PublishHTTPURL)
requireString(&errs, "eventlog.publish_http_method", c.EventLog.PublishHTTPMethod)
requirePositiveInt(&errs, "eventlog.publish_http_timeout_seconds", c.EventLog.PublishHTTPTimeout)
requireOneOf(&errs, "eventlog.publish_http_payload", c.EventLog.PublishHTTPPayload, "event", "payload")
}
}
}
if c.AccountCenter.Enabled {
requireOneOf(&errs, "account_center.store_kind", c.AccountCenter.StoreKind, "memory", "mysql")
if !strings.HasPrefix(c.AccountCenter.AdminPrefix, "/") {
errs = append(errs, fmt.Errorf("account_center.admin_prefix %q must start with /", c.AccountCenter.AdminPrefix))
}
if c.AccountCenter.StoreKind == "memory" && productionLike {
errs = append(errs, fmt.Errorf("account_center.store_kind memory is only allowed for local/dev/test account center paths"))
}
if c.AccountCenter.StoreKind == "mysql" {
requireString(&errs, "account_center.mysql_dsn", c.AccountCenter.MySQLDSN)
requireSQLIdentifier(&errs, "account_center.mysql_table", c.AccountCenter.MySQLTable)
}
}
if c.StorageObject.Enabled {
requireOneOf(&errs, "storage_object.store_kind", c.StorageObject.StoreKind, "memory", "mysql")
if !strings.HasPrefix(c.StorageObject.AdminPrefix, "/") {
errs = append(errs, fmt.Errorf("storage_object.admin_prefix %q must start with /", c.StorageObject.AdminPrefix))
}
if c.StorageObject.StoreKind == "memory" && productionLike {
errs = append(errs, fmt.Errorf("storage_object.store_kind memory is only allowed for local/dev/test storage object paths"))
}
if c.StorageObject.StoreKind == "mysql" {
requireString(&errs, "storage_object.mysql_dsn", c.StorageObject.MySQLDSN)
requireSQLIdentifier(&errs, "storage_object.mysql_table", c.StorageObject.MySQLTable)
}
}
if isBattleAgentService(c.Service.Name) {
requireOneOf(&errs, "battle_agent.settlement_outbox_kind", c.BattleAgent.SettlementOutboxKind, "memory", "eventlog")
requireSafeName(&errs, "battle_agent.settlement_outbox_stream", c.BattleAgent.SettlementOutboxStream)
requireSafeName(&errs, "battle_agent.settlement_outbox_type", c.BattleAgent.SettlementOutboxType)
if c.BattleAgent.SettlementOutboxKind == "eventlog" && !c.EventLog.Enabled {
errs = append(errs, fmt.Errorf("eventlog.enabled is required when battle_agent.settlement_outbox_kind is eventlog"))
}
if productionLike && c.BattleAgent.SettlementOutboxKind != "eventlog" {
errs = append(errs, fmt.Errorf("battle_agent.settlement_outbox_kind eventlog is required for production battleagent services"))
}
}
requirePositiveInt(&errs, "reload.poll_interval_seconds", c.Reload.PollIntervalSeconds)
requirePositiveInt(&errs, "reload.apply_timeout_seconds", c.Reload.ApplyTimeoutSeconds)
if c.HotUpdate.Enabled {
requireSafeName(&errs, "hot_update.target", c.HotUpdate.Target)
requireOneOf(&errs, "hot_update.control_kind", c.HotUpdate.ControlKind, "memory", "eventlog")
requireOneOf(&errs, "hot_update.source_kind", c.HotUpdate.SourceKind, "local")
requireOneOf(&errs, "hot_update.applier_kind", c.HotUpdate.ApplierKind, "datatable", "native_patch")
requireString(&errs, "hot_update.workspace", c.HotUpdate.Workspace)
requirePositiveInt(&errs, "hot_update.apply_timeout_seconds", c.HotUpdate.ApplyTimeoutSeconds)
requireNonNegInt(&errs, "hot_update.native_patch_max_symbols", c.HotUpdate.NativePatchMaxSymbols)
requireNonNegInt(&errs, "hot_update.native_patch_max_live_seconds", c.HotUpdate.NativePatchMaxLiveSeconds)
requireNonNegInt(&errs, "hot_update.native_patch_min_reason_length", c.HotUpdate.NativePatchMinReasonLength)
if c.HotUpdate.RequireSignature {
requireString(&errs, "hot_update.signing_key_env", c.HotUpdate.SigningKeyEnv)
}
validatePatchPolicy(&errs, c.HotUpdate, productionLike)
if c.HotUpdate.ControlKind == "eventlog" && !c.EventLog.Enabled {
errs = append(errs, fmt.Errorf("eventlog.enabled is required when hot_update.control_kind is eventlog"))
}
if productionLike && c.HotUpdate.ControlKind != "eventlog" {
errs = append(errs, fmt.Errorf("hot_update.control_kind eventlog is required for production-like hot update"))
}
}
if c.StateSync.Enabled {
requireOneOf(&errs, "state_sync.kind", c.StateSync.Kind, "memory")
requireSafeName(&errs, "state_sync.namespace", c.StateSync.Namespace)
requirePositiveInt(&errs, "state_sync.ttl_seconds", c.StateSync.TTLSeconds)
requirePositiveInt(&errs, "state_sync.cleanup_interval_seconds", c.StateSync.CleanupIntervalSeconds)
requirePositiveInt(&errs, "state_sync.max_payload_bytes", c.StateSync.MaxPayloadBytes)
}
if c.ServerGroup.Enabled {
requireOneOf(&errs, "server_group.store_kind", c.ServerGroup.StoreKind, "file")
requireString(&errs, "server_group.plan_file", c.ServerGroup.PlanFile)
requireString(&errs, "server_group.merge_archive_dir", c.ServerGroup.MergeArchiveDir)
if !strings.HasPrefix(c.ServerGroup.AdminPrefix, "/") {
errs = append(errs, fmt.Errorf("server_group.admin_prefix %q must start with /", c.ServerGroup.AdminPrefix))
}
requirePositiveInt(&errs, "server_group.in_zone_delay_seconds", c.ServerGroup.InZoneDelaySeconds)
requirePositiveInt(&errs, "server_group.notice_lead_seconds", c.ServerGroup.NoticeLeadSeconds)
}
if c.Topology.Enabled {
requireNonEmptySlice(&errs, "topology.services", c.Topology.Services)
requireSafeName(&errs, "topology.gateway_service", c.Topology.GatewayService)
requireSafeName(&errs, "topology.gateway_backend_service", c.Topology.GatewayBackendService)
requirePositiveInt(&errs, "topology.refresh_interval_seconds", c.Topology.RefreshIntervalSeconds)
for idx, service := range c.Topology.Services {
requireSafeName(&errs, fmt.Sprintf("topology.services[%d]", idx), service)
}
}
requirePositiveInt(&errs, "discovery.cache_ttl_seconds", c.Discovery.CacheTTLSeconds)
requirePositiveInt(&errs, "discovery.failure_ttl_seconds", c.Discovery.FailureTTLSeconds)
requirePositiveInt(&errs, "discovery.failure_threshold", c.Discovery.FailureThreshold)
requireOneOf(&errs, "discovery.strategy", c.Discovery.Strategy, "hash", "random", "round_robin", "weighted_round_robin")
requirePositiveInt(&errs, "rpc.call_timeout_seconds", c.RPC.CallTimeoutSeconds)
requirePositiveInt(&errs, "rpc.max_pending", c.RPC.MaxPending)
requirePositiveInt(&errs, "rpc.max_payload_bytes", c.RPC.MaxPayloadBytes)
requireOneOf(&errs, "idempotency.kind", c.Idempotency.Kind, "memory", "redis", "mysql", "mysql_redis")
if productionLike && isIdemCriticalSvc(c.Service.Name) && c.Idempotency.Kind != "mysql_redis" {
errs = append(errs, fmt.Errorf("idempotency.kind mysql_redis is required for production %s services", c.Service.Name))
}
requirePositiveInt(&errs, "idempotency.ttl_seconds", c.Idempotency.TTLSeconds)
requireSafeName(&errs, "idempotency.key_prefix", c.Idempotency.KeyPrefix)
if c.Idempotency.Kind == "redis" || c.Idempotency.Kind == "mysql_redis" {
requireString(&errs, "idempotency.redis_address", c.Idempotency.RedisAddress)
requireNonNegInt(&errs, "idempotency.redis_db", c.Idempotency.RedisDB)
requirePositiveInt(&errs, "idempotency.redis_pool_size", c.Idempotency.RedisPoolSize)
requirePositiveInt(&errs, "idempotency.redis_timeout_seconds", c.Idempotency.RedisTimeoutSeconds)
requirePositiveInt(&errs, "idempotency.redis_connect_timeout_seconds", c.Idempotency.RedisConnectTimeoutSecs)
requirePositiveInt(&errs, "idempotency.redis_read_timeout_seconds", c.Idempotency.RedisReadTimeoutSecs)
requirePositiveInt(&errs, "idempotency.redis_write_timeout_seconds", c.Idempotency.RedisWriteTimeoutSecs)
}
if c.Idempotency.Kind == "mysql" || c.Idempotency.Kind == "mysql_redis" {
requireString(&errs, "idempotency.mysql_dsn", c.Idempotency.MySQLDSN)
requirePositiveInt(&errs, "idempotency.mysql_max_open_conns", c.Idempotency.MySQLMaxOpenConns)
requirePositiveInt(&errs, "idempotency.mysql_max_idle_conns", c.Idempotency.MySQLMaxIdleConns)
requirePositiveInt(&errs, "idempotency.mysql_conn_max_lifetime_seconds", c.Idempotency.MySQLConnMaxLifetimeSec)
}
requireOneOf(&errs, "registry.kind", c.Registry.Kind, "memory", "redis", "etcd")
requireSafeName(&errs, "registry.namespace", c.Registry.Namespace)
requirePositiveInt64(&errs, "registry.lease_ttl", c.Registry.LeaseTTL)
if c.Registry.Kind == "redis" || c.Registry.Kind == "etcd" {
requireNonEmptySlice(&errs, "registry.endpoints", c.Registry.Endpoints)
}
if c.Registry.Kind == "redis" {
requireNonNegInt(&errs, "registry.redis_db", c.Registry.RedisDB)
}
requireOneOf(&errs, "bus.kind", c.Bus.Kind, "memory", "redis", "nats")
requireSafeName(&errs, "bus.namespace", c.Bus.Namespace)
if c.Bus.Kind == "redis" || c.Bus.Kind == "nats" {
requireNonEmptySlice(&errs, "bus.endpoints", c.Bus.Endpoints)
}
if c.Bus.Kind == "redis" {
requireNonNegInt(&errs, "bus.redis_db", c.Bus.RedisDB)
if c.Bus.RedisUsername != "" && c.Bus.RedisPassword == "" {
errs = append(errs, fmt.Errorf("bus.redis_password is required when bus.redis_username is set"))
}
}
if c.Bus.Kind == "nats" {
requireOneOf(&errs, "bus.nats_wire_encoding", c.Bus.NATSWireEncoding, "binary_v1", "json")
if c.Bus.NATSPassword != "" && c.Bus.NATSUsername == "" {
errs = append(errs, fmt.Errorf("bus.nats_username is required when bus.nats_password is set"))
}
if (c.Bus.NATSCertFile == "") != (c.Bus.NATSKeyFile == "") {
errs = append(errs, fmt.Errorf("bus.nats_cert_file and bus.nats_key_file must be configured together"))
}
}
requireOneOf(&errs, "cache.kind", c.Cache.Kind, "memory", "redis")
requireSafeName(&errs, "cache.key_prefix", c.Cache.KeyPrefix)
requirePositiveInt(&errs, "cache.default_ttl_seconds", c.Cache.DefaultTTLSeconds)
requirePositiveInt(&errs, "cache.max_entries", c.Cache.MaxEntries)
if c.Cache.Kind == "redis" {
requireString(&errs, "cache.redis_address", c.Cache.RedisAddress)
requireNonNegInt(&errs, "cache.redis_db", c.Cache.RedisDB)
requirePositiveInt(&errs, "cache.redis_pool_size", c.Cache.RedisPoolSize)
requirePositiveInt(&errs, "cache.redis_timeout_seconds", c.Cache.RedisTimeoutSeconds)
requirePositiveInt(&errs, "cache.redis_connect_timeout_seconds", c.Cache.RedisConnectTimeout)
requirePositiveInt(&errs, "cache.redis_read_timeout_seconds", c.Cache.RedisReadTimeout)
requirePositiveInt(&errs, "cache.redis_write_timeout_seconds", c.Cache.RedisWriteTimeout)
}
requireOneOf(&errs, "lock.kind", c.Lock.Kind, "memory", "redis")
requireSafeName(&errs, "lock.key_prefix", c.Lock.KeyPrefix)
requirePositiveInt(&errs, "lock.ttl_seconds", c.Lock.TTLSeconds)
if c.Lock.Kind == "redis" {
requireString(&errs, "lock.redis_address", c.Lock.RedisAddress)
requireNonNegInt(&errs, "lock.redis_db", c.Lock.RedisDB)
requirePositiveInt(&errs, "lock.redis_pool_size", c.Lock.RedisPoolSize)
requirePositiveInt(&errs, "lock.redis_timeout_seconds", c.Lock.RedisTimeoutSeconds)
requirePositiveInt(&errs, "lock.redis_connect_timeout_seconds", c.Lock.RedisConnectTimeout)
requirePositiveInt(&errs, "lock.redis_read_timeout_seconds", c.Lock.RedisReadTimeout)
requirePositiveInt(&errs, "lock.redis_write_timeout_seconds", c.Lock.RedisWriteTimeout)
}
requireOneOf(&errs, "presence.kind", c.Presence.Kind, "memory", "redis")
requireSafeName(&errs, "presence.key_prefix", c.Presence.KeyPrefix)
requirePositiveInt(&errs, "presence.ttl_seconds", c.Presence.TTLSeconds)
if c.Presence.Kind == "redis" {
requireString(&errs, "presence.redis_address", c.Presence.RedisAddress)
requireNonNegInt(&errs, "presence.redis_db", c.Presence.RedisDB)
requirePositiveInt(&errs, "presence.redis_pool_size", c.Presence.RedisPoolSize)
requirePositiveInt(&errs, "presence.redis_timeout_seconds", c.Presence.RedisTimeoutSeconds)
requirePositiveInt(&errs, "presence.redis_connect_timeout_seconds", c.Presence.RedisConnectTimeout)
requirePositiveInt(&errs, "presence.redis_read_timeout_seconds", c.Presence.RedisReadTimeout)
requirePositiveInt(&errs, "presence.redis_write_timeout_seconds", c.Presence.RedisWriteTimeout)
}
requirePositiveInt(&errs, "worker.size", c.Worker.Size)
requirePositiveInt(&errs, "worker.queue", c.Worker.Queue)
requireOneOf(&errs, "profile_store.store_kind", c.ProfileStore.StoreKind, "memory", "file", "json", "redis", "pika", "profiledb", "profile_db", "mysql", "mysql_redis")
if c.ProfileStore.StoreKind == "file" || c.ProfileStore.StoreKind == "json" {
requireString(&errs, "profile_store.store_directory", c.ProfileStore.StoreDirectory)
}
if c.ProfileStore.StoreKind == "redis" || c.ProfileStore.StoreKind == "pika" || c.ProfileStore.StoreKind == "profiledb" || c.ProfileStore.StoreKind == "profile_db" {
requireString(&errs, "profile_store.store_address", c.ProfileStore.StoreAddress)
requireString(&errs, "profile_store.store_key_prefix", c.ProfileStore.StoreKeyPrefix)
}
if c.ProfileStore.StoreKind == "mysql" || c.ProfileStore.StoreKind == "mysql_redis" {
if !c.ProfileStore.MySQLShardingEnabled && len(c.ProfileStore.MySQLShards) == 0 {
requireString(&errs, "profile_store.mysql_dsn", c.ProfileStore.MySQLDSN)
}
requirePositiveInt(&errs, "profile_store.mysql_max_open_conns", c.ProfileStore.MySQLMaxOpenConns)
requirePositiveInt(&errs, "profile_store.mysql_max_idle_conns", c.ProfileStore.MySQLMaxIdleConns)
requirePositiveInt(&errs, "profile_store.mysql_conn_max_lifetime_seconds", c.ProfileStore.MySQLConnMaxLifetime)
validateMySQLShards(&errs, c.ProfileStore)
} else if c.ProfileStore.MySQLShardingEnabled || len(c.ProfileStore.MySQLShards) > 0 {
errs = append(errs, fmt.Errorf("profile_store.mysql_shards require profile_store.store_kind mysql or mysql_redis"))
}
requireOneOf(&errs, "profile_store.summary_store_kind", c.ProfileStore.SummaryStoreKind, "memory", "redis", "pika")
if c.ProfileStore.SummaryStoreKind == "redis" || c.ProfileStore.SummaryStoreKind == "pika" {
requireString(&errs, "profile_store.summary_store_address", c.ProfileStore.SummaryStoreAddress)
requireString(&errs, "profile_store.summary_store_key_prefix", c.ProfileStore.SummaryStoreKeyPrefix)
requirePositiveInt(&errs, "profile_store.summary_store_pool_size", c.ProfileStore.SummaryStorePoolSize)
requirePositiveInt(&errs, "profile_store.summary_store_timeout_seconds", c.ProfileStore.SummaryStoreTimeout)
requirePositiveInt(&errs, "profile_store.summary_store_connect_timeout_seconds", c.ProfileStore.SummaryStoreConnectTimeout)
requirePositiveInt(&errs, "profile_store.summary_store_read_timeout_seconds", c.ProfileStore.SummaryStoreReadTimeout)
requirePositiveInt(&errs, "profile_store.summary_store_write_timeout_seconds", c.ProfileStore.SummaryStoreWriteTimeout)
requireNonNegInt(&errs, "profile_store.summary_store_db", c.ProfileStore.SummaryStoreDB)
requireNonNegInt(&errs, "profile_store.summary_store_ttl_seconds", c.ProfileStore.SummaryStoreTTL)
}
requireNonNegInt(&errs, "profile_store.store_db", c.ProfileStore.StoreDB)
requirePositiveInt(&errs, "profile_store.store_pool_size", c.ProfileStore.StorePoolSize)
requirePositiveInt(&errs, "profile_store.store_timeout_seconds", c.ProfileStore.StoreTimeoutSeconds)
requirePositiveInt(&errs, "profile_store.store_connect_timeout_seconds", c.ProfileStore.StoreConnectTimeout)
requirePositiveInt(&errs, "profile_store.store_read_timeout_seconds", c.ProfileStore.StoreReadTimeout)
requirePositiveInt(&errs, "profile_store.store_write_timeout_seconds", c.ProfileStore.StoreWriteTimeout)
requireNonNegInt(&errs, "profile_store.store_ttl_seconds", c.ProfileStore.StoreTTLSeconds)
requireOneOf(&errs, "profile_store.save_mode", c.ProfileStore.SaveMode, "sync", "async", "writebehind", "write_behind")
requirePositiveInt(&errs, "profile_store.async_flush_interval_seconds", c.ProfileStore.AsyncFlushIntervalSeconds)
requirePositiveInt(&errs, "profile_store.async_max_pending", c.ProfileStore.AsyncMaxPending)
requirePositiveInt(&errs, "profile_store.async_retry_backoff_seconds", c.ProfileStore.AsyncRetryBackoffSeconds)
requireNonNegInt(&errs, "profile_store.async_max_retries", c.ProfileStore.AsyncMaxRetries)
requireNonNegInt(&errs, "profile_store.async_auto_expire_seconds", c.ProfileStore.AsyncAutoExpireSeconds)
requirePositiveInt(&errs, "profile_store.async_dead_letter_limit", c.ProfileStore.AsyncDeadLetterLimit)
if isAsyncSaveMode(c.ProfileStore.SaveMode) {
requireString(&errs, "profile_store.async_dead_letter_directory", c.ProfileStore.AsyncDeadLetterDirectory)
if productionLike && c.ProfileStore.AsyncMaxRetries <= 0 {
errs = append(errs, fmt.Errorf("profile_store.async_max_retries must be positive for production-like async profile stores; 0 means retry forever"))
}
}
requireListenAddress(&errs, "gateway.auth_listen", c.Gateway.AuthListen)
requireOneOf(&errs, "gateway.auth_mode", c.Gateway.AuthMode, "mock", "external", "disabled")
if isGatewayService(c.Service.Name) && productionLike && c.Gateway.AuthMode != "external" {
errs = append(errs, fmt.Errorf("gateway.auth_mode external is required for production gateway services"))
}
if c.Gateway.RegistryGateSelection {
requireString(&errs, "gateway.gate_service", c.Gateway.GateService)
requireString(&errs, "gateway.gate_route_feature", c.Gateway.GateRouteFeature)
}
if c.Gateway.RemoteGateSelection && !c.Gateway.RegistryGateSelection {
errs = append(errs, fmt.Errorf("gateway.remote_gate_selection requires gateway.registry_gate_selection"))
}
if c.Gateway.RemoteGateSelection && !c.Gateway.SharedLoginTokens {
errs = append(errs, fmt.Errorf("gateway.remote_gate_selection requires gateway.shared_login_tokens"))
}
if isGatewayService(c.Service.Name) && productionLike && c.Gateway.RemoteGateSelection && c.Cache.Kind != "redis" {
errs = append(errs, fmt.Errorf("gateway.remote_gate_selection requires cache.kind=redis for production gateway services"))
}
requireListenAddress(&errs, "gateway.tcp_listen", c.Gateway.TCPListen)
if c.Gateway.TCPProxyProtocolEnabled {
requirePositiveInt(&errs, "gateway.tcp_proxy_header_timeout_seconds", c.Gateway.TCPProxyHeaderSec)
requireNonEmptySlice(&errs, "gateway.tcp_proxy_trusted_cidrs", c.Gateway.TCPProxyTrustedCIDRs)
for idx, value := range c.Gateway.TCPProxyTrustedCIDRs {
if err := validateIPOrCIDR(value); err != nil {
errs = append(errs, fmt.Errorf("gateway.tcp_proxy_trusted_cidrs[%d]: %w", idx, err))
}
}
}
if c.Gateway.KCPEnabled {
requireListenAddress(&errs, "gateway.kcp_listen", c.Gateway.KCPListen)
requireNonNegInt(&errs, "gateway.kcp_nodelay", c.Gateway.KCPNoDelay)
requirePositiveInt(&errs, "gateway.kcp_interval", c.Gateway.KCPInterval)
requireNonNegInt(&errs, "gateway.kcp_resend", c.Gateway.KCPResend)
requireNonNegInt(&errs, "gateway.kcp_no_congestion", c.Gateway.KCPNoCongestion)
requirePositiveInt(&errs, "gateway.kcp_window_size", c.Gateway.KCPWindowSize)
requireNonNegInt(&errs, "gateway.kcp_data_shards", c.Gateway.KCPDataShards)
requireNonNegInt(&errs, "gateway.kcp_parity_shards", c.Gateway.KCPParityShards)
}
if c.Gateway.QUICEnabled {
requireListenAddress(&errs, "gateway.quic_listen", c.Gateway.QUICListen)
requirePositiveInt(&errs, "gateway.quic_handshake_timeout_seconds", c.Gateway.QUICHandshakeSec)
requirePositiveInt(&errs, "gateway.quic_max_idle_timeout_seconds", c.Gateway.QUICMaxIdleTimeoutSeconds)
requireNonNegInt(&errs, "gateway.quic_keep_alive_seconds", c.Gateway.QUICKeepAliveSeconds)
requirePositiveInt64(&errs, "gateway.quic_max_incoming_streams", c.Gateway.QUICMaxIncomingStreams)
if (c.Gateway.QUICCertFile == "") != (c.Gateway.QUICKeyFile == "") {
errs = append(errs, fmt.Errorf("gateway.quic_cert_file and gateway.quic_key_file must be set together"))
}
if productionLike && c.Gateway.QUICCertFile == "" && c.Gateway.QUICKeyFile == "" {
errs = append(errs, fmt.Errorf("gateway.quic_cert_file and gateway.quic_key_file are required for production-like QUIC"))
}
}
if c.Gateway.WebSocketEnabled {
requireListenAddress(&errs, "gateway.websocket_listen", c.Gateway.WebSocketListen)
if !strings.HasPrefix(c.Gateway.WebSocketPath, "/") {
errs = append(errs, fmt.Errorf("gateway.websocket_path %q must start with /", c.Gateway.WebSocketPath))
}
requirePositiveInt(&errs, "gateway.websocket_max_payload_bytes", c.Gateway.WebSocketMaxPayloadBytes)
requirePositiveInt(&errs, "gateway.websocket_heartbeat_seconds", c.Gateway.WebSocketHeartbeatSeconds)
}
requireListenAddress(&errs, "gateway.chat_listen", c.Gateway.ChatListen)
requireSafeName(&errs, "gateway.backend_service", c.Gateway.BackendService)
requirePositiveInt(&errs, "gateway.backend_poll_interval_seconds", c.Gateway.BackendPollIntervalSeconds)
requirePositiveInt(&errs, "gateway.max_connections", c.Gateway.MaxConnections)
requirePositiveInt(&errs, "gateway.connection_rate_limit_window_seconds", c.Gateway.ConnRateWindowSec)
requirePositiveInt(&errs, "gateway.connection_rate_limit_max", c.Gateway.ConnectionRateLimitMax)
requirePositiveInt(&errs, "gateway.connection_rate_limit_max_entries", c.Gateway.ConnRateMaxEntries)
if c.Gateway.ConnRateIPv4Prefix < 1 || c.Gateway.ConnRateIPv4Prefix > 32 {
errs = append(errs, fmt.Errorf("gateway.connection_rate_limit_ipv4_prefix must be between 1 and 32"))
}
if c.Gateway.ConnRateIPv6Prefix < 1 || c.Gateway.ConnRateIPv6Prefix > 128 {
errs = append(errs, fmt.Errorf("gateway.connection_rate_limit_ipv6_prefix must be between 1 and 128"))
}
requirePositiveInt(&errs, "gateway.session_idle_timeout_seconds", c.Gateway.SessionIdleTimeoutSeconds)
requirePositiveInt(&errs, "gateway.session_sweep_interval_seconds", c.Gateway.SessionSweepSec)
requirePositiveInt(&errs, "gateway.session_hook_timeout_seconds", c.Gateway.SessionHookTimeoutSeconds)
requireString(&errs, "gateway.account_id", c.Gateway.AccountID)
requirePositiveInt32(&errs, "gateway.account_num_id", c.Gateway.AccountNumID)
if c.Gateway.AuthMode == "mock" {
requireString(&errs, "gateway.auth_token", c.Gateway.AuthToken)
requireString(&errs, "gateway.login_token", c.Gateway.LoginToken)
}
requireString(&errs, "gateway.server_version", c.Gateway.ServerVersion)
requirePositiveInt32(&errs, "gateway.shard_id", c.Gateway.ShardID)
requireString(&errs, "gateway.shard_name", c.Gateway.ShardName)
requireString(&errs, "gateway.shard_display", c.Gateway.ShardDisplay)
requirePositiveInt32(&errs, "gateway.shard_state", c.Gateway.ShardState)
requirePositiveInt32(&errs, "gateway.recommend", c.Gateway.Recommend)
requireString(&errs, "gateway.recommend_lang", c.Gateway.RecommendLang)
requirePositiveInt32(&errs, "gateway.virtual_gid", c.Gateway.VirtualGID)
requireNonEmptySlice(&errs, "gateway.product_ids", c.Gateway.ProductIDs)
return errors.Join(errs...)
}
func validateIPOrCIDR(value string) error {
value = strings.TrimSpace(value)
if value == "" {
return fmt.Errorf("empty value")
}
if net.ParseIP(value) != nil {
return nil
}
if _, _, err := net.ParseCIDR(value); err != nil {
return fmt.Errorf("invalid IP or CIDR %q", value)
}
return nil
}
@@ -0,0 +1,165 @@
package config
import (
"fmt"
"net"
"slices"
"strconv"
"strings"
)
func requireString(errs *[]error, field, value string) {
if strings.TrimSpace(value) == "" {
*errs = append(*errs, fmt.Errorf("%s is required", field))
}
}
func isAsyncSaveMode(mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "async", "writebehind", "write_behind":
return true
default:
return false
}
}
func requireSafeName(errs *[]error, field, value string) {
requireString(errs, field, value)
if strings.TrimSpace(value) == "" {
return
}
for _, r := range value {
if isSafeNameRune(r) {
continue
}
*errs = append(*errs, fmt.Errorf("%s %q can only contain letters, digits, dot, dash, or underscore", field, value))
return
}
if strings.HasPrefix(value, ".") || strings.HasSuffix(value, ".") || strings.Contains(value, "..") {
*errs = append(*errs, fmt.Errorf("%s %q cannot start with dot, end with dot, or contain consecutive dots", field, value))
}
}
func requireSQLIdentifier(errs *[]error, field, value string) {
requireString(errs, field, value)
if strings.TrimSpace(value) == "" {
return
}
for idx, r := range value {
if idx == 0 {
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
continue
}
*errs = append(*errs, fmt.Errorf("%s %q must start with a letter or underscore", field, value))
return
}
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
*errs = append(*errs, fmt.Errorf("%s %q can only contain letters, digits, or underscore", field, value))
return
}
}
func isSafeNameRune(r rune) bool {
return r == '.' || r == '-' || r == '_' ||
(r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9')
}
func requireNonEmptySlice(errs *[]error, field string, values []string) {
if len(values) == 0 {
*errs = append(*errs, fmt.Errorf("%s requires at least one value", field))
return
}
for idx, value := range values {
if strings.TrimSpace(value) == "" {
*errs = append(*errs, fmt.Errorf("%s[%d] is required", field, idx))
}
}
}
func requireOneOf(errs *[]error, field, value string, allowed ...string) {
if !slices.Contains(allowed, value) {
*errs = append(*errs, fmt.Errorf("%s %q must be one of %s", field, value, strings.Join(allowed, ", ")))
}
}
func requirePositiveInt(errs *[]error, field string, value int) {
if value <= 0 {
*errs = append(*errs, fmt.Errorf("%s must be positive", field))
}
}
func requirePositiveInt32(errs *[]error, field string, value int32) {
if value <= 0 {
*errs = append(*errs, fmt.Errorf("%s must be positive", field))
}
}
func requirePositiveInt64(errs *[]error, field string, value int64) {
if value <= 0 {
*errs = append(*errs, fmt.Errorf("%s must be positive", field))
}
}
func requireNonNegInt(errs *[]error, field string, value int) {
if value < 0 {
*errs = append(*errs, fmt.Errorf("%s must be non-negative", field))
}
}
func requireNonNegInt64(errs *[]error, field string, value int64) {
if value < 0 {
*errs = append(*errs, fmt.Errorf("%s must be non-negative", field))
}
}
func requireListenAddress(errs *[]error, field, value string) {
requireString(errs, field, value)
if strings.TrimSpace(value) == "" {
return
}
_, port, err := net.SplitHostPort(value)
if err != nil {
*errs = append(*errs, fmt.Errorf("%s %q must be host:port or :port: %w", field, value, err))
return
}
if strings.TrimSpace(port) == "" {
*errs = append(*errs, fmt.Errorf("%s %q must include a port", field, value))
return
}
if _, err := net.LookupPort("tcp", port); err != nil {
*errs = append(*errs, fmt.Errorf("%s %q has invalid port %q: %w", field, value, port, err))
}
}
func validRateRules(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
for _, part := range strings.Split(raw, ";") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
fields := strings.Split(part, ",")
if len(fields) != 3 {
return fmt.Errorf("rate_limit.rules %q must use path,window_seconds,max_requests", part)
}
if strings.TrimSpace(fields[0]) == "" {
return fmt.Errorf("rate_limit.rules %q has empty path", part)
}
window, err := strconv.Atoi(strings.TrimSpace(fields[1]))
if err != nil || window <= 0 {
return fmt.Errorf("rate_limit.rules %q has invalid window_seconds", part)
}
max, err := strconv.Atoi(strings.TrimSpace(fields[2]))
if err != nil || max <= 0 {
return fmt.Errorf("rate_limit.rules %q has invalid max_requests", part)
}
}
return nil
}
@@ -0,0 +1,55 @@
package config
import (
"fmt"
"strings"
)
func validatePatchPolicy(errs *[]error, section HotUpdateSection, productionLike bool) {
if section.ApplierKind != "native_patch" {
return
}
for idx, target := range section.NativePatchAllowedTargets {
requireSafeName(errs, fmt.Sprintf("hot_update.native_patch_allowed_targets[%d]", idx), target)
}
for idx, symbol := range section.PatchOldSymbols {
field := fmt.Sprintf("hot_update.native_patch_allowed_old_symbols[%d]", idx)
requireString(errs, field, symbol)
if strings.TrimSpace(symbol) != symbol || strings.ContainsAny(symbol, "\r\n") {
*errs = append(*errs, fmt.Errorf("%s cannot contain surrounding whitespace or line breaks", field))
}
}
if !productionLike {
if section.NativePatchMaxLiveSeconds <= 0 {
*errs = append(*errs, fmt.Errorf("hot_update.native_patch_max_live_seconds is required for native patch"))
}
return
}
if !section.RequireSignature {
*errs = append(*errs, fmt.Errorf("hot_update.require_signature is required for production-like native patch"))
}
if strings.TrimSpace(section.SigningKeyEnv) == "" {
*errs = append(*errs, fmt.Errorf("hot_update.signing_key_env is required for production-like native patch"))
}
if !section.NativePatchRequireBuildID {
*errs = append(*errs, fmt.Errorf("hot_update.native_patch_require_build_id is required for production-like native patch"))
}
if !section.PatchRequireActor {
*errs = append(*errs, fmt.Errorf("hot_update.native_patch_require_requested_by is required for production-like native patch"))
}
if !section.NativePatchRequireReason {
*errs = append(*errs, fmt.Errorf("hot_update.native_patch_require_reason is required for production-like native patch"))
}
if section.NativePatchMinReasonLength <= 0 {
*errs = append(*errs, fmt.Errorf("hot_update.native_patch_min_reason_length is required for production-like native patch"))
}
if len(section.PatchOldSymbols) == 0 {
*errs = append(*errs, fmt.Errorf("hot_update.native_patch_allowed_old_symbols requires at least one value for production-like native patch"))
}
if section.NativePatchMaxLiveSeconds <= 0 {
*errs = append(*errs, fmt.Errorf("hot_update.native_patch_max_live_seconds is required for production-like native patch"))
}
if section.NativePatchMaxLiveSeconds > 3600 {
*errs = append(*errs, fmt.Errorf("hot_update.native_patch_max_live_seconds must be <= 3600 for production-like native patch"))
}
}