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 }