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
298 lines
15 KiB
Go
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
|
|
}
|