proxy-pool/internal/config/validate.go
2026-08-07 15:27:17 +08:00

967 lines
34 KiB
Go

package config
import (
"fmt"
"math"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"proxy-pool/internal/domain/authorization"
"proxy-pool/internal/domain/clientpolicy"
)
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.Admin.Enabled {
if err := validateNoClientPolicy("admin", cfg.Admin.Auth); 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))
gatewayRoutings := 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 && route.Purpose == "gateway" {
gatewayRoutings[route.Name] = struct{}{}
}
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.Gateway.Enabled {
if err := validateGatewayClientPolicies(cfg.Gateway.Auth, gatewayRoutings); err != nil {
return err
}
if err := validateGatewayStickySession(cfg.Gateway); err != nil {
return err
}
if err := validateGatewayTransport(cfg.Gateway); err != nil {
return err
}
}
if cfg.Distribution.Enabled {
if err := validateDistributionClientPolicies(cfg.Distribution.Auth, cfg.Upstreams); err != nil {
return err
}
if cfg.Distribution.StickySession.Enabled {
return fmt.Errorf("validate distribution stickySession: only supported on gateway")
}
if gatewayTransportConfigured(cfg.Distribution.Transport) {
return fmt.Errorf("validate distribution transport: only supported on gateway")
}
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
}
}
if cfg.Admin.Enabled && cfg.Admin.StickySession.Enabled {
return fmt.Errorf("validate admin stickySession: only supported on gateway")
}
if cfg.Admin.Enabled && gatewayTransportConfigured(cfg.Admin.Transport) {
return fmt.Errorf("validate admin transport: only supported on gateway")
}
return nil
}
func validateGatewayStickySession(listener Listener) error {
item := listener.StickySession
if !item.Enabled {
return nil
}
if listener.Auth.Mode == "" || listener.Auth.Mode == "none" {
return fmt.Errorf("validate gateway stickySession: requires authentication")
}
if !validCanonicalHeaderName(item.Header) {
return fmt.Errorf("validate gateway stickySession.header: must be a canonical HTTP header name")
}
if item.TTL.Value() <= 0 {
return fmt.Errorf("validate gateway stickySession.ttl: must be greater than zero")
}
if item.MaxEntries <= 0 || item.MaxEntries > MaximumPoolSize {
return fmt.Errorf("validate gateway stickySession.maxEntries: must be in [1, %d]", MaximumPoolSize)
}
return nil
}
func validateGatewayTransport(listener Listener) error {
item := listener.Transport
for _, timeout := range []struct {
name string
value Duration
}{
{name: "dialTimeout", value: item.DialTimeout},
{name: "handshakeTimeout", value: item.HandshakeTimeout},
{name: "responseHeaderTimeout", value: item.ResponseHeaderTimeout},
{name: "idleConnTimeout", value: item.IdleConnTimeout},
{name: "tunnelIdleTimeout", value: item.TunnelIdleTimeout},
} {
if timeout.value.Value() < 0 {
return fmt.Errorf("validate gateway transport.%s: must be non-negative", timeout.name)
}
}
for _, limit := range []struct {
name string
value int
}{
{name: "maxIdleConns", value: item.MaxIdleConns},
{name: "maxIdleConnsPerHost", value: item.MaxIdleConnsPerHost},
{name: "maxConnsPerHost", value: item.MaxConnsPerHost},
{name: "tunnelBufferBytes", value: item.TunnelBufferBytes},
} {
if limit.value < 0 || limit.value > MaximumPoolSize {
return fmt.Errorf("validate gateway transport.%s: must be in [0, %d]", limit.name, MaximumPoolSize)
}
}
if item.MaxIdleConns > 0 && item.MaxIdleConnsPerHost > item.MaxIdleConns {
return fmt.Errorf("validate gateway transport.maxIdleConnsPerHost: must not exceed maxIdleConns")
}
return nil
}
func gatewayTransportConfigured(item GatewayTransport) bool {
return item.DialTimeout != 0 ||
item.HandshakeTimeout != 0 ||
item.ResponseHeaderTimeout != 0 ||
item.IdleConnTimeout != 0 ||
item.MaxIdleConns != 0 ||
item.MaxIdleConnsPerHost != 0 ||
item.MaxConnsPerHost != 0 ||
item.TunnelBufferBytes != 0 ||
item.TunnelIdleTimeout != 0
}
func validCanonicalHeaderName(value string) bool {
if value == "" || http.CanonicalHeaderKey(value) != value {
return false
}
for _, character := range []byte(value) {
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' || strings.ContainsRune("!#$%&'*+-.^_|~", rune(character)) ||
character == 96 {
continue
}
return false
}
return true
}
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 {
if err := authorization.Validate(auth.Permissions); err != nil {
return fmt.Errorf("validate %s auth.permissions: %w", listener, err)
}
if err := auth.ClientPolicy.Validate(); err != nil {
return fmt.Errorf("validate %s auth.client: %w", listener, err)
}
switch auth.Mode {
case "none":
if len(auth.Permissions) != 0 {
return fmt.Errorf("validate %s auth.permissions: requires an authentication mode", listener)
}
if !auth.ClientPolicy.IsZero() {
return fmt.Errorf("validate %s auth.client: requires an authentication mode", listener)
}
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.Permissions) != 0 {
return fmt.Errorf("validate %s auth.permissions: configure permissions on auth.methods", listener)
}
if !auth.ClientPolicy.IsZero() {
return fmt.Errorf("validate %s auth.client: configure client policy on auth.methods", listener)
}
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 {
if err := authorization.Validate(method.Permissions); err != nil {
return fmt.Errorf("validate %s auth.methods[%d].permissions: %w", listener, index, err)
}
if err := method.ClientPolicy.Validate(); err != nil {
return fmt.Errorf("validate %s auth.methods[%d].client: %w", listener, index, err)
}
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 validateNoClientPolicy(listener string, auth Auth) error {
if !auth.ClientPolicy.IsZero() {
return fmt.Errorf("validate %s auth.client: client extraction policy is only supported on distribution", listener)
}
for index, method := range auth.Methods {
if !method.ClientPolicy.IsZero() {
return fmt.Errorf("validate %s auth.methods[%d].client: client extraction policy is only supported on distribution", listener, index)
}
}
return nil
}
func validateDistributionClientPolicies(auth Auth, upstreams map[string]Upstream) error {
if err := validateDistributionClientPolicy("distribution auth.client", auth.ClientPolicy, upstreams); err != nil {
return err
}
for index, method := range auth.Methods {
if err := validateDistributionClientPolicy(
fmt.Sprintf("distribution auth.methods[%d].client", index), method.ClientPolicy, upstreams,
); err != nil {
return err
}
}
return nil
}
func validateDistributionClientPolicy(scope string, policy clientpolicy.Policy, upstreams map[string]Upstream) error {
if len(policy.AllowedRoutings) != 0 || policy.RequestsPerMinute != 0 || policy.MaxConcurrentConnections != 0 {
return fmt.Errorf("validate %s: routing access and Gateway limits are only supported on gateway", scope)
}
for _, upstream := range policy.AllowedUpstreams {
if _, exists := upstreams[upstream]; !exists {
return fmt.Errorf("validate %s.allowedUpstreams: upstream %q does not exist", scope, upstream)
}
}
return nil
}
func validateGatewayClientPolicies(auth Auth, routings map[string]struct{}) error {
if err := validateGatewayClientPolicy("gateway auth.client", auth.ClientPolicy, routings); err != nil {
return err
}
for index, method := range auth.Methods {
if err := validateGatewayClientPolicy(
fmt.Sprintf("gateway auth.methods[%d].client", index), method.ClientPolicy, routings,
); err != nil {
return err
}
}
return nil
}
func validateGatewayClientPolicy(scope string, policy clientpolicy.Policy, routings map[string]struct{}) error {
if policy.MaxExtractCount != 0 || len(policy.AllowedUpstreams) != 0 || len(policy.AllowedRegions) != 0 {
return fmt.Errorf("validate %s: extraction constraints are only supported on distribution", scope)
}
if int64(policy.RequestsPerMinute) > MaximumExactCounter {
return fmt.Errorf("validate %s.requestsPerMinute: exceeds exact counter range", scope)
}
if int64(policy.MaxConcurrentConnections) > MaximumExactCounter {
return fmt.Errorf("validate %s.maxConcurrentConnections: exceeds exact counter range", scope)
}
for _, routing := range policy.AllowedRoutings {
if _, exists := routings[routing]; !exists {
return fmt.Errorf("validate %s.allowedRoutings: routing %q does not exist or is not an enabled gateway routing", scope, routing)
}
}
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()
}