Files
tokens-reef/backend/internal/config/config_validate.go
User a4eb4d4c3a refactor(config): split config.go into modular files
Split the monolithic config.go (~120KB) into focused modules:
- auth.go: JWT, TOTP, Turnstile, RateLimit configs
- billing.go: Billing and Pricing configs
- database.go: Database and Redis configs
- gateway.go: Gateway and Upstream configs
- gateway_sub.go: Gateway sub-configurations
- ops_and_cache.go: Ops and Cache configs
- platforms.go: Platform-specific configs
- security.go: Security-related configs
- server.go: Server configuration
- config_defaults.go: Default values
- config_defaults_detail.go: Detailed defaults
- config_helpers.go: Helper functions
- config_validate.go: Validation logic
- config_validate_gateway.go: Gateway validation

This improves:
- Code maintainability and readability
- Faster compilation (smaller files)
- Easier navigation and debugging
- Better separation of concerns
2026-04-17 07:22:55 +08:00

298 lines
15 KiB
Go

package config
import (
"fmt"
"log/slog"
"net/url"
"strings"
)
// Validate validates the configuration. Returns error on any invalid field.
func (c *Config) Validate() error {
if err := validateJWT(&c.JWT); err != nil { return err }
if err := validateLog(&c.Log); err != nil { return err }
if err := validateServerURL(c.Server.FrontendURL); err != nil { return err }
if err := validateLinuxDo(&c.LinuxDo); err != nil { return err }
if err := validateOIDC(&c.OIDC); err != nil { return err }
if err := validateBilling(&c.Billing); err != nil { return err }
if err := validateDatabase(&c.Database); err != nil { return err }
if err := validateRedis(&c.Redis); err != nil { return err }
if err := validateDashboard(&c.Dashboard, &c.DashboardAgg); err != nil { return err }
if err := validateUsageCleanup(&c.UsageCleanup); err != nil { return err }
if err := validateIdempotency(&c.Idempotency); err != nil { return err }
if err := validateGateway(&c.Gateway); err != nil { return err }
if err := validateOps(&c.Ops); err != nil { return err }
if err := validateConcurrency(&c.Concurrency); err != nil { return err }
return nil
}
func validateJWT(j *JWTConfig) error {
s := strings.TrimSpace(j.Secret)
if s == "" { return fmt.Errorf("jwt.secret is required") }
if len([]byte(s)) < 32 { return fmt.Errorf("jwt.secret must be at least 32 bytes") }
if j.ExpireHour <= 0 { return fmt.Errorf("jwt.expire_hour must be positive") }
if j.ExpireHour > 168 { return fmt.Errorf("jwt.expire_hour must be <= 168 (7 days)") }
if j.ExpireHour > 24 {
slog.Warn("jwt.expire_hour is high; consider shorter expiration for security", "expire_hour", j.ExpireHour)
}
if j.AccessTokenExpireMinutes < 0 { return fmt.Errorf("jwt.access_token_expire_minutes must be non-negative") }
if j.AccessTokenExpireMinutes > 720 {
slog.Warn("jwt.access_token_expire_minutes is high", "access_token_expire_minutes", j.AccessTokenExpireMinutes)
}
if j.RefreshTokenExpireDays <= 0 { return fmt.Errorf("jwt.refresh_token_expire_days must be positive") }
if j.RefreshTokenExpireDays > 90 {
slog.Warn("jwt.refresh_token_expire_days is high", "refresh_token_expire_days", j.RefreshTokenExpireDays)
}
if j.RefreshWindowMinutes < 0 { return fmt.Errorf("jwt.refresh_window_minutes must be non-negative") }
return nil
}
func validateLog(l *LogConfig) error {
switch l.Level {
case "debug", "info", "warn", "error":
case "":
return fmt.Errorf("log.level is required")
default:
return fmt.Errorf("log.level must be one of: debug/info/warn/error")
}
switch l.Format {
case "json", "console":
case "":
return fmt.Errorf("log.format is required")
default:
return fmt.Errorf("log.format must be one of: json/console")
}
switch l.StacktraceLevel {
case "none", "error", "fatal":
case "":
return fmt.Errorf("log.stacktrace_level is required")
default:
return fmt.Errorf("log.stacktrace_level must be one of: none/error/fatal")
}
if !l.Output.ToStdout && !l.Output.ToFile {
return fmt.Errorf("log.output.to_stdout and log.output.to_file cannot both be false")
}
if l.Rotation.MaxSizeMB <= 0 { return fmt.Errorf("log.rotation.max_size_mb must be positive") }
if l.Rotation.MaxBackups < 0 { return fmt.Errorf("log.rotation.max_backups must be non-negative") }
if l.Rotation.MaxAgeDays < 0 { return fmt.Errorf("log.rotation.max_age_days must be non-negative") }
if l.Sampling.Enabled {
if l.Sampling.Initial <= 0 { return fmt.Errorf("log.sampling.initial must be positive when sampling is enabled") }
if l.Sampling.Thereafter <= 0 { return fmt.Errorf("log.sampling.thereafter must be positive when sampling is enabled") }
} else {
if l.Sampling.Initial < 0 { return fmt.Errorf("log.sampling.initial must be non-negative") }
if l.Sampling.Thereafter < 0 { return fmt.Errorf("log.sampling.thereafter must be non-negative") }
}
return nil
}
func validateServerURL(frontendURL string) error {
if strings.TrimSpace(frontendURL) == "" { return nil }
if err := ValidateAbsoluteHTTPURL(frontendURL); err != nil {
return fmt.Errorf("server.frontend_url invalid: %w", err)
}
u, err := url.Parse(strings.TrimSpace(frontendURL))
if err != nil { return fmt.Errorf("server.frontend_url invalid: %w", err) }
if u.RawQuery != "" || u.ForceQuery { return fmt.Errorf("server.frontend_url invalid: must not include query") }
if u.User != nil { return fmt.Errorf("server.frontend_url invalid: must not include userinfo") }
warnIfInsecureURL("server.frontend_url", frontendURL)
return nil
}
func validateLinuxDo(lc *LinuxDoConnectConfig) error {
if !lc.Enabled { return nil }
requiredStringFields := map[string]string{
"client_id": lc.ClientID,
"authorize_url": lc.AuthorizeURL,
"token_url": lc.TokenURL,
"userinfo_url": lc.UserInfoURL,
"redirect_url": lc.RedirectURL,
"frontend_redirect_url": lc.FrontendRedirectURL,
}
for k, v := range requiredStringFields {
if strings.TrimSpace(v) == "" {
return fmt.Errorf("linuxdo_connect.%s is required when linuxdo_connect.enabled=true", k)
}
}
method := strings.ToLower(strings.TrimSpace(lc.TokenAuthMethod))
switch method {
case "", "client_secret_post", "client_secret_basic", "none":
default:
return fmt.Errorf("linuxdo_connect.token_auth_method must be one of: client_secret_post/client_secret_basic/none")
}
if method == "none" && !lc.UsePKCE {
return fmt.Errorf("linuxdo_connect.use_pkce must be true when token_auth_method=none")
}
if (method == "" || method == "client_secret_post" || method == "client_secret_basic") &&
strings.TrimSpace(lc.ClientSecret) == "" {
return fmt.Errorf("linuxdo_connect.client_secret is required when enabled=true and token_auth_method is client_secret_post/client_secret_basic")
}
urlsToValidate := []struct{ key, val string }{
{"authorize_url", lc.AuthorizeURL}, {"token_url", lc.TokenURL},
{"userinfo_url", lc.UserInfoURL}, {"redirect_url", lc.RedirectURL},
}
for _, u := range urlsToValidate {
if err := ValidateAbsoluteHTTPURL(u.val); err != nil {
return fmt.Errorf("linuxdo_connect.%s invalid: %w", u.key, err)
}
warnIfInsecureURL("linuxdo_connect."+u.key, u.val)
}
if err := ValidateFrontendRedirectURL(lc.FrontendRedirectURL); err != nil {
return fmt.Errorf("linuxdo_connect.frontend_redirect_url invalid: %w", err)
}
warnIfInsecureURL("linuxdo_connect.frontend_redirect_url", lc.FrontendRedirectURL)
return nil
}
func validateOIDC(oc *OIDCConnectConfig) error {
if !oc.Enabled { return nil }
if strings.TrimSpace(oc.ClientID) == "" { return fmt.Errorf("oidc_connect.client_id is required when enabled=true") }
if strings.TrimSpace(oc.IssuerURL) == "" { return fmt.Errorf("oidc_connect.issuer_url is required when enabled=true") }
if strings.TrimSpace(oc.RedirectURL) == "" { return fmt.Errorf("oidc_connect.redirect_url is required when enabled=true") }
if strings.TrimSpace(oc.FrontendRedirectURL) == "" { return fmt.Errorf("oidc_connect.frontend_redirect_url is required when enabled=true") }
if !scopeContainsOpenID(oc.Scopes) { return fmt.Errorf("oidc_connect.scopes must contain openid") }
method := strings.ToLower(strings.TrimSpace(oc.TokenAuthMethod))
switch method { case "", "client_secret_post", "client_secret_basic", "none": default: return fmt.Errorf("oidc_connect.token_auth_method must be valid") }
if method == "none" && !oc.UsePKCE { return fmt.Errorf("oidc_connect.use_pkce must be true when token_auth_method=none") }
if (method == "" || method == "client_secret_post" || method == "client_secret_basic") && strings.TrimSpace(oc.ClientSecret) == "" {
return fmt.Errorf("oidc_connect.client_secret is required when enabled=true")
}
if oc.ClockSkewSeconds < 0 || oc.ClockSkewSeconds > 600 { return fmt.Errorf("oidc_connect.clock_skew_seconds must be between 0-600") }
if oc.ValidateIDToken && strings.TrimSpace(oc.AllowedSigningAlgs) == "" { return fmt.Errorf("oidc_connect.allowed_signing_algs required when validate_id_token=true") }
// Validate URLs (only if set — discovery can auto-populate these)
for _, u := range []struct{k, v string}{{"issuer_url", oc.IssuerURL}, {"redirect_url", oc.RedirectURL}, {"frontend_redirect_url", oc.FrontendRedirectURL}} {
if err := ValidateAbsoluteHTTPURL(u.v); err != nil { return fmt.Errorf("oidc_connect.%s invalid: %w", u.k, err) }
warnIfInsecureURL("oidc_connect."+u.k, u.v)
}
for _, k := range []string{"discovery_url", "authorize_url", "token_url", "userinfo_url", "jwks_url"} {
v := getOIDCStringField(oc, k)
if v != "" {
if err := ValidateAbsoluteHTTPURL(v); err != nil { return fmt.Errorf("oidc_connect.%s invalid: %w", k, err) }
warnIfInsecureURL("oidc_connect."+k, v)
}
}
if err := ValidateFrontendRedirectURL(oc.FrontendRedirectURL); err != nil { return fmt.Errorf("oidc_connect.frontend_redirect_url invalid: %w", err) }
return nil
}
func getOIDCStringField(oc *OIDCConnectConfig, field string) string {
switch field {
case "discovery_url": return oc.DiscoveryURL
case "authorize_url": return oc.AuthorizeURL
case "token_url": return oc.TokenURL
case "userinfo_url": return oc.UserInfoURL
case "jwks_url": return oc.JWKSURL
}
return ""
}
func validateBilling(b *BillingConfig) error {
if !b.CircuitBreaker.Enabled { return nil }
if b.CircuitBreaker.FailureThreshold <= 0 { return fmt.Errorf("billing.circuit_breaker.failure_threshold must be positive") }
if b.CircuitBreaker.ResetTimeoutSeconds <= 0 { return fmt.Errorf("billing.circuit_breaker.reset_timeout_seconds must be positive") }
if b.CircuitBreaker.HalfOpenRequests <= 0 { return fmt.Errorf("billing.circuit_breaker.half_open_requests must be positive") }
return nil
}
func validateDatabase(d *DatabaseConfig) error {
if d.MaxOpenConns <= 0 { return fmt.Errorf("database.max_open_conns must be positive") }
if d.MaxIdleConns < 0 { return fmt.Errorf("database.max_idle_conns must be non-negative") }
if d.MaxIdleConns > d.MaxOpenConns { return fmt.Errorf("database.max_idle_conns cannot exceed max_open_conns") }
if d.ConnMaxLifetimeMinutes < 0 { return fmt.Errorf("database.conn_max_lifetime_minutes must be non-negative") }
if d.ConnMaxIdleTimeMinutes < 0 { return fmt.Errorf("database.conn_max_idle_time_minutes must be non-negative") }
return nil
}
func validateRedis(r *RedisConfig) error {
if r.DialTimeoutSeconds <= 0 { return fmt.Errorf("redis.dial_timeout_seconds must be positive") }
if r.ReadTimeoutSeconds <= 0 { return fmt.Errorf("redis.read_timeout_seconds must be positive") }
if r.WriteTimeoutSeconds <= 0 { return fmt.Errorf("redis.write_timeout_seconds must be positive") }
if r.PoolSize <= 0 { return fmt.Errorf("redis.pool_size must be positive") }
if r.MinIdleConns < 0 { return fmt.Errorf("redis.min_idle_conns must be non-negative") }
if r.MinIdleConns > r.PoolSize { return fmt.Errorf("redis.min_idle_conns cannot exceed pool_size") }
return nil
}
func validateDashboard(d *DashboardCacheConfig, da *DashboardAggregationConfig) error {
if d.Enabled {
if d.StatsFreshTTLSeconds <= 0 { return fmt.Errorf("dashboard_cache.stats_fresh_ttl_seconds must be positive") }
if d.StatsTTLSeconds <= 0 { return fmt.Errorf("dashboard_cache.stats_ttl_seconds must be positive") }
if d.StatsRefreshTimeoutSeconds <= 0 { return fmt.Errorf("dashboard_cache.stats_refresh_timeout_seconds must be positive") }
if d.StatsFreshTTLSeconds > d.StatsTTLSeconds { return fmt.Errorf("stats_fresh_ttl_seconds must be <= stats_ttl_seconds") }
} else {
if d.StatsFreshTTLSeconds < 0 || d.StatsTTLSeconds < 0 || d.StatsRefreshTimeoutSeconds < 0 {
return fmt.Errorf("dashboard cache fields must be non-negative when disabled")
}
}
return validateDashboardAgg(da)
}
func validateDashboardAgg(da *DashboardAggregationConfig) error {
if !da.Enabled {
// Non-enabled: all fields just need to be non-negative where numeric
if da.IntervalSeconds < 0 || da.LookbackSeconds < 0 || da.BackfillMaxDays < 0 ||
da.Retention.UsageLogsDays < 0 || da.Retention.UsageBillingDedupDays < 0 ||
da.Retention.HourlyDays < 0 || da.Retention.DailyDays < 0 || da.RecomputeDays < 0 {
return fmt.Errorf("dashboard_aggregation numeric fields must be non-negative when disabled")
}
return nil
}
if da.IntervalSeconds <= 0 { return fmt.Errorf("dashboard_aggregation.interval_seconds must be positive") }
if da.LookbackSeconds < 0 { return fmt.Errorf("lookback_seconds must be non-negative") }
if da.BackfillMaxDays < 0 { return fmt.Errorf("backfill_max_days must be non-negative") }
if da.BackfillEnabled && da.BackfillMaxDays == 0 { return fmt.Errorf("backfill_max_days must be positive when backfill_enabled") }
if da.Retention.UsageLogsDays <= 0 { return fmt.Errorf("retention.usage_logs_days must be positive") }
if da.Retention.UsageBillingDedupDays <= 0 { return fmt.Errorf("retention.usage_billing_dedup_days must be positive") }
if da.Retention.UsageBillingDedupDays < da.Retention.UsageLogsDays {
return fmt.Errorf("usage_billing_dedup_days >= usage_logs_days")
}
if da.Retention.HourlyDays <= 0 { return fmt.Errorf("retention.hourly_days must be positive") }
if da.Retention.DailyDays <= 0 { return fmt.Errorf("retention.daily_days must be positive") }
if da.RecomputeDays < 0 { return fmt.Errorf("recompute_days must be non-negative") }
return nil
}
func validateUsageCleanup(uc *UsageCleanupConfig) error {
if !uc.Enabled {
if uc.MaxRangeDays < 0 || uc.BatchSize < 0 || uc.WorkerIntervalSeconds < 0 || uc.TaskTimeoutSeconds < 0 {
return fmt.Errorf("usage_cleanup fields must be non-negative when disabled")
}
return nil
}
if uc.MaxRangeDays <= 0 { return fmt.Errorf("usage_cleanup.max_range_days must be positive") }
if uc.BatchSize <= 0 { return fmt.Errorf("usage_cleanup.batch_size must be positive") }
if uc.WorkerIntervalSeconds <= 0 { return fmt.Errorf("usage_cleanup.worker_interval_seconds must be positive") }
if uc.TaskTimeoutSeconds <= 0 { return fmt.Errorf("usage_cleanup.task_timeout_seconds must be positive") }
return nil
}
func validateIdempotency(i *IdempotencyConfig) error {
if i.DefaultTTLSeconds <= 0 { return fmt.Errorf("default_ttl_seconds must be positive") }
if i.SystemOperationTTLSeconds <= 0 { return fmt.Errorf("system_operation_ttl_seconds must be positive") }
if i.ProcessingTimeoutSeconds <= 0 { return fmt.Errorf("processing_timeout_seconds must be positive") }
if i.FailedRetryBackoffSeconds <= 0 { return fmt.Errorf("failed_retry_backoff_seconds must be positive") }
if i.MaxStoredResponseLen <= 0 { return fmt.Errorf("max_stored_response_len must be positive") }
if i.CleanupIntervalSeconds <= 0 { return fmt.Errorf("cleanup_interval_seconds must be positive") }
if i.CleanupBatchSize <= 0 { return fmt.Errorf("cleanup_batch_size must be positive") }
return nil
}
func validateOps(o *OpsConfig) error {
if o.MetricsCollectorCache.TTL < 0 { return fmt.Errorf("ops.metrics_collector_cache.ttl must be non-negative") }
if o.Cleanup.ErrorLogRetentionDays < 0 || o.Cleanup.MinuteMetricsRetentionDays < 0 || o.Cleanup.HourlyMetricsRetentionDays < 0 {
return fmt.Errorf("ops cleanup retention days must be non-negative")
}
if o.Cleanup.Enabled && strings.TrimSpace(o.Cleanup.Schedule) == "" {
return fmt.Errorf("ops.cleanup.schedule is required when enabled=true")
}
return nil
}
func validateConcurrency(c *ConcurrencyConfig) error {
if c.PingInterval < 5 || c.PingInterval > 30 {
return fmt.Errorf("concurrency.ping_interval must be between 5-30 seconds")
}
return nil
}