package config import ( "fmt" "math" "net" "net/url" "regexp" "strconv" "strings" ) func Validate(cfg *Config) error { if cfg == nil { return fmt.Errorf("validate configuration: nil config") } if cfg.Version != 1 { return fmt.Errorf("validate configuration: version must be 1") } listeners := []struct { name string item Listener }{ {name: "gateway", item: cfg.Gateway}, {name: "distribution", item: cfg.Distribution.Listener}, {name: "admin", item: cfg.Admin}, } for _, listener := range listeners { if err := validateListener(listener.name, listener.item, cfg.Security); err != nil { return err } } if cfg.Metrics.Enabled { if cfg.Metrics.Listen == "" { return fmt.Errorf("validate metrics listen: address is required") } if _, err := validateListenAddress("metrics", cfg.Metrics.Listen); err != nil { return err } } if fetchConfigured(cfg.Defaults.Fetch) { if err := validateFetch("defaults.fetch", cfg.Defaults.Fetch); err != nil { return err } } if err := validateCheck("defaults.check", cfg.Defaults.Check); err != nil { return err } enabledUpstreams := 0 for name, upstream := range cfg.Upstreams { if upstream.Enabled { enabledUpstreams++ } if err := validateUpstream(name, upstream); err != nil { return err } } if enabledUpstreams == 0 { return fmt.Errorf("validate configuration: at least one enabled upstream is required") } seen := make(map[string]struct{}, len(cfg.Routing)) for index, route := range cfg.Routing { if err := validateRouting(index, route, cfg.Upstreams, seen); err != nil { return err } } if cfg.Distribution.Enabled { clientIdentificationMode := cfg.Distribution.ClientIdentification.Mode if clientIdentificationMode == "" { clientIdentificationMode = "sourceIP" } if err := validateEnum("distribution.clientIdentification.mode", clientIdentificationMode, "sourceIP", "authenticatedClient", "authenticatedClientOrSourceIP"); err != nil { return err } if clientIdentificationMode == "authenticatedClient" && cfg.Distribution.Auth.Mode == "none" { return fmt.Errorf("validate distribution clientIdentification.mode: authenticatedClient requires authentication") } if err := requirePositive("distribution.maxCountPerRequest", cfg.Distribution.Extraction.MaxCountPerRequest); err != nil { return err } if err := validateEnum("distribution.fulfillment", cfg.Distribution.Extraction.Fulfillment, "partial", "allOrNothing"); err != nil { return err } if err := requireNonNegative("distribution.minRemainingTTL", cfg.Distribution.Extraction.MinRemainingTTL); err != nil { return err } if err := requireNonNegative("distribution.maxHealthCheckAge", cfg.Distribution.Extraction.MaxHealthCheckAge); err != nil { return err } if err := requireNonNegative("distribution.reserveForGateway", cfg.Distribution.Extraction.ReserveForGateway); err != nil { return err } if err := requireNonNegative("distribution.idempotencyTTL", cfg.Distribution.Extraction.IdempotencyTTL); err != nil { return err } } return nil } func validateListener(name string, listener Listener, security Security) error { if !listener.Enabled { return nil } if listener.Listen == "" { return fmt.Errorf("validate %s: listen is required", name) } if err := validateListenerAuth(name, listener.Auth); err != nil { return err } for _, limit := range []struct { name string value int }{ {name: "maxConcurrentConnections", value: listener.Limits.MaxConcurrentConnections}, {name: "requestsPerMinute", value: listener.Limits.RequestsPerMinute}, {name: "requestsPerMinutePerClient", value: listener.Limits.RequestsPerMinutePerClient}, } { if limit.value < 0 { return fmt.Errorf("validate %s limits.%s: must be non-negative", name, limit.name) } } host, err := validateListenAddress(name, listener.Listen) if err != nil { return err } if security.RequireProtectionOnPublicListen && isPublicHost(host) && listener.Auth.Mode == "none" && len(listener.Access.AllowCIDRs) == 0 { return fmt.Errorf("validate %s: unprotected public listener is forbidden", name) } if err := validateCIDRs(name+" allowCIDRs", listener.Access.AllowCIDRs); err != nil { return err } if err := validateCIDRs(name+" trustedProxies", listener.Access.TrustedProxies); err != nil { return err } if err := validateCIDRs(name+" destinationPolicy.denyCIDRs", listener.DestinationPolicy.DenyCIDRs); err != nil { return err } seenPorts := make(map[uint16]struct{}, len(listener.DestinationPolicy.AllowedPorts)) for _, port := range listener.DestinationPolicy.AllowedPorts { if port == 0 { return fmt.Errorf("validate %s destinationPolicy.allowedPorts: port must be greater than zero", name) } if _, exists := seenPorts[port]; exists { return fmt.Errorf("validate %s destinationPolicy.allowedPorts: duplicate port %d", name, port) } seenPorts[port] = struct{}{} } return nil } func validateListenAddress(name, address string) (string, error) { host, port, err := net.SplitHostPort(address) if err != nil { return "", fmt.Errorf("validate %s listen: %w", name, err) } if _, err = strconv.ParseUint(port, 10, 16); err != nil { return "", fmt.Errorf("validate %s listen: invalid port", name) } return host, nil } func validateRouting(index int, route Routing, upstreams map[string]Upstream, seen map[string]struct{}) error { if route.Name == "" { return fmt.Errorf("validate routing[%d]: name is required", index) } if _, ok := seen[route.Name]; ok { return fmt.Errorf("validate routing %q: duplicate name", route.Name) } seen[route.Name] = struct{}{} scope := fmt.Sprintf("routing %q", route.Name) if err := validateEnum(scope+" purpose", route.Purpose, "gateway", "extract"); err != nil { return err } if route.Match.HostRegex != "" { if _, err := regexp.Compile(route.Match.HostRegex); err != nil { return fmt.Errorf("validate %s hostRegex: %w", scope, err) } } if route.Match.PathRegex != "" { if _, err := regexp.Compile(route.Match.PathRegex); err != nil { return fmt.Errorf("validate %s pathRegex: %w", scope, err) } } for _, upstream := range route.Upstreams { if _, ok := upstreams[upstream]; !ok { return fmt.Errorf("validate %s: upstream %q does not exist", scope, upstream) } } if err := validateStrategy(scope, route.Upstreams, route.Strategy); err != nil { return err } if err := validateEnum(scope+" onUnavailable.action", route.OnUnavailable.Action, "reject", "wait", "direct"); err != nil { return err } if route.OnUnavailable.WaitTimeout < 0 { return fmt.Errorf("validate %s onUnavailable.waitTimeout: must be non-negative", scope) } if route.OnUnavailable.Action == "wait" && route.OnUnavailable.WaitTimeout <= 0 { return fmt.Errorf("validate %s onUnavailable.waitTimeout: must be greater than zero for wait", scope) } return nil } func validateStrategy(scope string, upstreams []string, strategy Strategy) error { if err := validateEnum(scope+" strategy.type", strategy.Type, "sequential", "random", "roundRobin", "weighted", "leastConnections"); err != nil { return err } if strategy.Type == "sequential" { if len(upstreams) < 2 { return fmt.Errorf("validate %s strategy: sequential requires at least two upstreams", scope) } if err := requirePositive(scope+" strategy.switchAfterEmptyFetch", strategy.SwitchAfterEmptyFetch); err != nil { return err } if strategy.EndBehavior != "" { if err := validateEnum(scope+" strategy.endBehavior", strategy.EndBehavior, "stop", "loop", "stayLast"); err != nil { return err } } } if strategy.Type != "weighted" { if len(strategy.Weights) > 0 { return fmt.Errorf("validate %s strategy.weights: only weighted strategy accepts weights", scope) } return nil } if len(strategy.Weights) == 0 { return fmt.Errorf("validate %s strategy.weights: one positive weight per upstream is required", scope) } upstreamSet := make(map[string]struct{}, len(upstreams)) for _, upstream := range upstreams { upstreamSet[upstream] = struct{}{} weight, ok := strategy.Weights[upstream] if !ok { return fmt.Errorf("validate %s strategy.weights: upstream %q has no weight", scope, upstream) } if err := requirePositive(scope+" strategy weight for "+upstream, weight); err != nil { return err } } for upstream := range strategy.Weights { if _, ok := upstreamSet[upstream]; !ok { return fmt.Errorf("validate %s strategy.weights: upstream %q is not referenced", scope, upstream) } } return nil } func validateListenerAuth(listener string, auth Auth) error { switch auth.Mode { case "none": return nil case "usernamePassword": if auth.Username == "" || (auth.Password == "" && auth.PasswordFile == "") { return fmt.Errorf("validate %s auth.mode usernamePassword: username and password are required", listener) } case "apiKey": if auth.Header == "" || (auth.Token == "" && auth.TokenFile == "") { return fmt.Errorf("validate %s auth.mode apiKey: header and token are required", listener) } case "bearer": if auth.Token == "" && auth.TokenFile == "" { return fmt.Errorf("validate %s auth.mode bearer: token is required", listener) } case "ipWhitelist": if len(auth.CIDRs) == 0 { return fmt.Errorf("validate %s auth.mode ipWhitelist: cidrs are required", listener) } if err := validateCIDRs(listener+" auth", auth.CIDRs); err != nil { return err } case "any": if len(auth.Methods) == 0 { return fmt.Errorf("validate %s auth.mode any: methods are required", listener) } for index, method := range auth.Methods { if err := validateAuthMethod(listener, index, method); err != nil { return err } } default: return fmt.Errorf("validate %s auth.mode: unsupported value %q", listener, auth.Mode) } return nil } func validateAuthMethod(listener string, index int, method AuthMethod) error { switch method.Mode { case "usernamePassword": if method.Username == "" || (method.Password == "" && method.PasswordFile == "") { return fmt.Errorf("validate %s auth.methods[%d]: username and password are required", listener, index) } case "apiKey": if method.Header == "" || (method.Value == "" && method.ValueFile == "") { return fmt.Errorf("validate %s auth.methods[%d]: header and value are required", listener, index) } case "bearer": if method.Value == "" && method.ValueFile == "" { return fmt.Errorf("validate %s auth.methods[%d]: bearer value is required", listener, index) } case "ipWhitelist": if len(method.CIDRs) == 0 { return fmt.Errorf("validate %s auth.methods[%d]: cidrs are required", listener, index) } if err := validateCIDRs(listener+" auth method", method.CIDRs); err != nil { return err } default: return fmt.Errorf("validate %s auth.methods[%d]: unsupported mode %q", listener, index, method.Mode) } return nil } func validateCIDRs(name string, cidrs []string) error { for _, cidr := range cidrs { if _, _, err := net.ParseCIDR(cidr); err != nil { return fmt.Errorf("validate %s CIDR %q: %w", name, cidr, err) } } return nil } func validateUpstream(name string, upstream Upstream) error { if !upstream.Enabled { return nil } scope := fmt.Sprintf("upstream %q", name) if err := requirePositive(scope+" pool.maxSize", upstream.Pool.MaxSize); err != nil { return err } if err := requireNonNegative(scope+" fetch.maxTotal", upstream.Fetch.MaxTotal); err != nil { return err } if upstream.Fetch.MaxTotal > 0 && upstream.Fetch.MaxTotal < upstream.Pool.MaxSize { return fmt.Errorf("validate %s fetch.maxTotal: cannot be lower than pool.maxSize", scope) } if err := requirePositive(scope+" capacity.maxConcurrencyPerProxy", upstream.Capacity.MaxConcurrencyPerProxy); err != nil { return err } if err := requirePositive(scope+" refill.reconcileInterval", upstream.Refill.ReconcileInterval); err != nil { return err } if err := requirePositive(scope+" refill.minimumAvailableSlots", upstream.Refill.MinimumAvailableSlots); err != nil { return err } if upstream.Refill.TargetAvailableSlots <= upstream.Refill.MinimumAvailableSlots { return fmt.Errorf("validate %s refill.targetAvailableSlots: must be greater than minimumAvailableSlots", scope) } if int64(upstream.Pool.MaxSize) > math.MaxInt64/int64(upstream.Capacity.MaxConcurrencyPerProxy) { return fmt.Errorf("validate %s refill.targetAvailableSlots: theoretical capacity overflows int64", scope) } theoreticalSlots := int64(upstream.Pool.MaxSize) * int64(upstream.Capacity.MaxConcurrencyPerProxy) if upstream.Refill.TargetAvailableSlots > theoreticalSlots { return fmt.Errorf("validate %s refill.targetAvailableSlots: exceeds theoretical capacity", scope) } if err := requirePositive(scope+" lifecycle.ttl", upstream.Lifecycle.TTL); err != nil { return err } if err := requireNonNegative(scope+" lifecycle.allocationSafetyMargin", upstream.Lifecycle.AllocationSafetyMargin); err != nil { return err } if upstream.Lifecycle.TTL > 0 && upstream.Lifecycle.AllocationSafetyMargin >= upstream.Lifecycle.TTL { return fmt.Errorf("validate %s lifecycle.allocationSafetyMargin: must be lower than ttl", scope) } if err := validateFetch(scope+" fetch", upstream.Fetch); err != nil { return err } if upstream.Fetch.EstimatedIPsPerCall > upstream.Pool.MaxSize { return fmt.Errorf("validate %s fetch.estimatedIPsPerCall: cannot exceed pool.maxSize", scope) } if upstream.Fetch.MaxTotal > 0 && upstream.Fetch.EstimatedIPsPerCall > upstream.Fetch.MaxTotal { return fmt.Errorf("validate %s fetch.estimatedIPsPerCall: cannot exceed fetch.maxTotal", scope) } if err := validateCheck(scope+" check", upstream.Check); err != nil { return err } if upstream.API.URL != "" { parsed, err := url.Parse(upstream.API.URL) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return fmt.Errorf("validate upstream %q: api.url is invalid", name) } } if err := validateProviderAuth(name, upstream.API.Auth); err != nil { return err } if err := validateProxyAuth(name, upstream.ProxyAuth); err != nil { return err } if len(upstream.Exposure) == 0 { return fmt.Errorf("validate upstream %q: exposure is required", name) } for _, exposure := range upstream.Exposure { if exposure != "gateway" && exposure != "extract" { return fmt.Errorf("validate upstream %q: unsupported exposure %q", name, exposure) } } return nil } func validateFetch(scope string, fetch Fetch) error { if err := requirePositive(scope+".estimatedIPsPerCall", fetch.EstimatedIPsPerCall); err != nil { return err } if err := requireNonNegative(scope+".requestInterval", fetch.RequestInterval); err != nil { return err } if err := requirePositive(scope+".timeout", fetch.Timeout); err != nil { return err } if err := requirePositive(scope+".maxAttempts", fetch.MaxAttempts); err != nil { return err } if err := requirePositive(scope+".maxInFlight", fetch.MaxInFlight); err != nil { return err } if err := requireNonNegative(scope+".maxTotal", fetch.MaxTotal); err != nil { return err } if err := requireNonNegative(scope+".maxResponseBytes", fetch.MaxResponseBytes); err != nil { return err } if err := requireNonNegative(scope+".templateTimeout", fetch.TemplateTimeout); err != nil { return err } if err := requirePercentage(scope+".retry.jitter", fetch.Retry.Jitter); err != nil { return err } if err := requireNonNegative(scope+".retry.initial", fetch.Retry.Initial); err != nil { return err } if err := requireNonNegative(scope+".retry.max", fetch.Retry.Max); err != nil { return err } if (fetch.Retry.Initial == 0) != (fetch.Retry.Max == 0) { return fmt.Errorf("validate %s.retry: initial and max must be configured together", scope) } if fetch.Retry.Max > 0 && fetch.Retry.Max < fetch.Retry.Initial { return fmt.Errorf("validate %s.retry.max: must not be lower than initial", scope) } return nil } func validateCheck(scope string, check Check) error { if !checkConfigured(check) { return nil } if err := requirePositive(scope+".interval", check.Interval); err != nil { return err } if err := requirePercentage(scope+".jitter", check.Jitter); err != nil { return err } if err := requirePositive(scope+".maxInFlight", check.MaxInFlight); err != nil { return err } if err := requirePositive(scope+".timeout", check.Timeout); err != nil { return err } if err := requirePositive(scope+".maxAttempts", check.MaxAttempts); err != nil { return err } if err := requirePositive(scope+".maxConsecutiveFailures", check.MaxConsecutiveFailures); err != nil { return err } return nil } func fetchConfigured(fetch Fetch) bool { return fetch.EstimatedIPsPerCall != 0 || fetch.RequestInterval != 0 || fetch.Timeout != 0 || fetch.MaxAttempts != 0 || fetch.MaxInFlight != 0 || fetch.MaxTotal != 0 || fetch.MaxResponseBytes != 0 || fetch.TemplateTimeout != 0 || fetch.Retry.Initial != 0 || fetch.Retry.Max != 0 || fetch.Retry.Jitter != 0 } func checkConfigured(check Check) bool { return check.Interval != 0 || check.Jitter != 0 || check.MaxInFlight != 0 || check.Timeout != 0 || check.MaxAttempts != 0 || check.MaxConsecutiveFailures != 0 || len(check.URLs) != 0 } type validationNumber interface { ~int | ~int64 } func requirePositive[T validationNumber](field string, value T) error { if value <= 0 { return fmt.Errorf("validate %s: must be greater than zero", field) } return nil } func requireNonNegative[T validationNumber](field string, value T) error { if value < 0 { return fmt.Errorf("validate %s: must be non-negative", field) } return nil } func requirePercentage(field string, value int) error { if value < 0 || value > 100 { return fmt.Errorf("validate %s: must be between 0 and 100", field) } return nil } func validateEnum(field, value string, allowed ...string) error { for _, candidate := range allowed { if value == candidate { return nil } } return fmt.Errorf("validate %s: unsupported value %q; allowed values: %s", field, value, strings.Join(allowed, ", ")) } func validateProviderAuth(upstream string, auth ProviderAuth) error { switch auth.Type { case "", "none": return nil case "basic": if auth.Username == "" || (auth.Password == "" && auth.PasswordFile == "") { return fmt.Errorf("validate upstream %q: basic api.auth requires username and password", upstream) } case "bearer": if auth.Token == "" && auth.TokenFile == "" { return fmt.Errorf("validate upstream %q: bearer api.auth requires token", upstream) } case "apiKey": if auth.Location != "header" && auth.Location != "query" { return fmt.Errorf("validate upstream %q: apiKey location must be header or query", upstream) } if auth.Name == "" || (auth.Value == "" && auth.ValueFile == "") { return fmt.Errorf("validate upstream %q: apiKey requires name and value", upstream) } default: return fmt.Errorf("validate upstream %q: unsupported api.auth type %q", upstream, auth.Type) } return nil } func validateProxyAuth(upstream string, auth ProxyAuth) error { switch auth.Type { case "response", "ipWhitelist": return nil case "static": if auth.Username == "" || (auth.Password == "" && auth.PasswordFile == "") { return fmt.Errorf("validate upstream %q: static proxyAuth requires username and password", upstream) } case "": return fmt.Errorf("validate upstream %q: proxyAuth.type is required", upstream) default: return fmt.Errorf("validate upstream %q: unsupported proxyAuth type %q", upstream, auth.Type) } return nil } func isPublicHost(host string) bool { host = strings.Trim(host, "[]") if host == "localhost" { return false } ip := net.ParseIP(host) if ip == nil { return true } return !ip.IsLoopback() }