752 lines
26 KiB
Go
752 lines
26 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
controlPlaneIdentityPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
|
trustDomainLabelPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
|
|
)
|
|
|
|
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 err := validateControlPlane(cfg.ControlPlane); 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
|
|
}
|
|
if len(cfg.Upstreams) > MaximumUpstreams {
|
|
return fmt.Errorf("validate configuration: upstream count exceeds %d", MaximumUpstreams)
|
|
}
|
|
enabledUpstreams := 0
|
|
for name, upstream := range cfg.Upstreams {
|
|
if upstream.Enabled {
|
|
enabledUpstreams++
|
|
}
|
|
effective := upstream
|
|
if upstream.Enabled {
|
|
effective.Check = EffectiveCheck(cfg.Defaults.Check, upstream.Check)
|
|
}
|
|
if err := validateUpstream(name, effective); 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))
|
|
targetProfilesByUpstream := make(map[string]int, len(cfg.Upstreams))
|
|
for index, route := range cfg.Routing {
|
|
if err := validateRouting(index, route, cfg.Upstreams, seen); err != nil {
|
|
return err
|
|
}
|
|
if !route.Enabled || len(route.Check.Targets) == 0 {
|
|
continue
|
|
}
|
|
for _, upstreamID := range route.Upstreams {
|
|
upstream, exists := cfg.Upstreams[upstreamID]
|
|
if !exists || !upstream.Enabled {
|
|
continue
|
|
}
|
|
targetProfilesByUpstream[upstreamID] += len(route.Check.Targets)
|
|
if targetProfilesByUpstream[upstreamID] > MaximumTargetProfilesPerUpstream {
|
|
return fmt.Errorf("validate upstream %q routing target profiles: supports at most %d", upstreamID, MaximumTargetProfilesPerUpstream)
|
|
}
|
|
}
|
|
}
|
|
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 validateControlPlane(item ControlPlane) error {
|
|
if err := validateClientTLS("gatewayTLS", item.GatewayTLS); err != nil {
|
|
return err
|
|
}
|
|
if err := validateClientTLS("checkerTLS", item.CheckerTLS); err != nil {
|
|
return err
|
|
}
|
|
if !item.Enabled {
|
|
return nil
|
|
}
|
|
host, err := validateListenAddress("controlPlane", item.Listen)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if item.ProtocolVersion != 1 {
|
|
return fmt.Errorf("validate controlPlane protocolVersion: must be 1")
|
|
}
|
|
heartbeat := item.HeartbeatInterval.Value()
|
|
if heartbeat <= 0 {
|
|
return fmt.Errorf("validate controlPlane heartbeatInterval: must be greater than zero")
|
|
}
|
|
if item.SessionTTL.Value()/3 < heartbeat {
|
|
return fmt.Errorf("validate controlPlane sessionTTL: must be at least three heartbeat intervals")
|
|
}
|
|
if item.MaxStaleAge.Value() < heartbeat {
|
|
return fmt.Errorf("validate controlPlane maxStaleAge: must not be shorter than heartbeatInterval")
|
|
}
|
|
if item.MaxMessageBytes <= 0 || item.MaxMessageBytes > 64<<20 {
|
|
return fmt.Errorf("validate controlPlane maxMessageBytes: must be in [1, 67108864]")
|
|
}
|
|
if item.MaxRuntimeCounters <= 0 || item.MaxRuntimeCounters > MaximumPoolSize {
|
|
return fmt.Errorf("validate controlPlane maxRuntimeCounters: must be in [1, %d]", MaximumPoolSize)
|
|
}
|
|
if item.MaxConcurrentStreams == 0 {
|
|
return fmt.Errorf("validate controlPlane maxConcurrentStreams: must be positive")
|
|
}
|
|
switch item.TLS.Mode {
|
|
case "disabled":
|
|
if isPublicHost(host) {
|
|
return fmt.Errorf("validate controlPlane tls: non-loopback listen requires mtls")
|
|
}
|
|
case "mtls":
|
|
if item.TLS.CertFile == "" || item.TLS.KeyFile == "" || item.TLS.ClientCAFile == "" ||
|
|
item.TLS.TrustDomain == "" || item.TLS.Environment == "" {
|
|
return fmt.Errorf("validate controlPlane tls: mtls requires certFile, keyFile, clientCAFile, trustDomain and environment")
|
|
}
|
|
if !validTrustDomain(item.TLS.TrustDomain) {
|
|
return fmt.Errorf("validate controlPlane tls.trustDomain: must be a lowercase DNS name without port")
|
|
}
|
|
if !controlPlaneIdentityPattern.MatchString(item.TLS.Environment) {
|
|
return fmt.Errorf("validate controlPlane tls.environment: must be one URI path segment")
|
|
}
|
|
default:
|
|
return fmt.Errorf("validate controlPlane tls.mode: must be disabled or mtls")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateGatewayTLS(item GatewayTLS) error {
|
|
return validateClientTLS("gatewayTLS", item)
|
|
}
|
|
|
|
func validateClientTLS(name string, item ClientTLS) error {
|
|
configured := item.CertFile != "" || item.KeyFile != "" || item.ServerCAFile != ""
|
|
if !configured {
|
|
return nil
|
|
}
|
|
if item.CertFile == "" || item.KeyFile == "" || item.ServerCAFile == "" {
|
|
return fmt.Errorf("validate controlPlane %s: certFile, keyFile and serverCAFile are required together", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validTrustDomain(value string) bool {
|
|
if len(value) == 0 || len(value) > 253 {
|
|
return false
|
|
}
|
|
for _, label := range strings.Split(value, ".") {
|
|
if !trustDomainLabelPattern.MatchString(label) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
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)
|
|
}
|
|
if int64(limit.value) > MaximumExactCounter {
|
|
return fmt.Errorf("validate %s limits.%s: exceeds exact counter range", 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 len(route.Check.Targets) > 0 && !validTargetRoutingName(route.Name) {
|
|
return fmt.Errorf("validate %s check.targets: routing name is not a valid target profile identifier", scope)
|
|
}
|
|
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)
|
|
}
|
|
if err := validateCheckURLs(scope+" check.targets", route.Check.Targets); err != nil {
|
|
return err
|
|
}
|
|
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 upstream.Pool.MaxSize > MaximumPoolSize {
|
|
return fmt.Errorf("validate %s pool.maxSize: exceeds %d", scope, MaximumPoolSize)
|
|
}
|
|
if err := requireNonNegative(scope+" fetch.maxTotal", upstream.Fetch.MaxTotal); err != nil {
|
|
return err
|
|
}
|
|
if int64(upstream.Fetch.MaxTotal) > MaximumExactCounter {
|
|
return fmt.Errorf("validate %s fetch.maxTotal: exceeds exact counter range", scope)
|
|
}
|
|
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 int64(upstream.Capacity.MaxConcurrencyPerProxy) > MaximumExactCounter {
|
|
return fmt.Errorf("validate %s capacity.maxConcurrencyPerProxy: exceeds exact counter range", scope)
|
|
}
|
|
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.MinimumAvailableSlots > MaximumExactCounter {
|
|
return fmt.Errorf("validate %s refill.minimumAvailableSlots: exceeds exact counter range", scope)
|
|
}
|
|
if upstream.Refill.TargetAvailableSlots <= upstream.Refill.MinimumAvailableSlots {
|
|
return fmt.Errorf("validate %s refill.targetAvailableSlots: must be greater than minimumAvailableSlots", scope)
|
|
}
|
|
if upstream.Refill.TargetAvailableSlots > MaximumExactCounter {
|
|
return fmt.Errorf("validate %s refill.targetAvailableSlots: exceeds exact counter range", 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 theoreticalSlots > MaximumExactCounter {
|
|
return fmt.Errorf("validate %s refill.targetAvailableSlots: theoretical capacity exceeds exact counter range", scope)
|
|
}
|
|
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 int64(fetch.MaxInFlight) > MaximumExactCounter {
|
|
return fmt.Errorf("validate %s.maxInFlight: exceeds exact counter range", scope)
|
|
}
|
|
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
|
|
}
|
|
if err := requireNonNegative(scope+".unhealthyRemoveAfter", check.UnhealthyRemoveAfter); err != nil {
|
|
return err
|
|
}
|
|
return validateCheckURLs(scope+".urls", check.URLs)
|
|
}
|
|
|
|
func validateCheckURLs(scope string, urls []string) error {
|
|
if len(urls) > MaximumCheckURLs {
|
|
return fmt.Errorf("validate %s: supports at most %d URLs", scope, MaximumCheckURLs)
|
|
}
|
|
seenURLs := make(map[string]struct{}, len(urls))
|
|
for index, rawURL := range urls {
|
|
if rawURL == "" || strings.TrimSpace(rawURL) != rawURL {
|
|
return fmt.Errorf("validate %s[%d]: must be an absolute http or https URL", scope, index)
|
|
}
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" ||
|
|
(parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
return fmt.Errorf("validate %s[%d]: must be an absolute http or https URL", scope, index)
|
|
}
|
|
canonical := parsed.String()
|
|
if _, exists := seenURLs[canonical]; exists {
|
|
return fmt.Errorf("validate %s[%d]: duplicate URL", scope, index)
|
|
}
|
|
seenURLs[canonical] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validTargetRoutingName(value string) bool {
|
|
if value == "" || len(value) > 256 || strings.TrimSpace(value) != value {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
if character <= ' ' || character == '\x7f' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
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 ||
|
|
check.UnhealthyRemoveAfter != 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()
|
|
}
|