486 lines
16 KiB
Go
486 lines
16 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"regexp"
|
|
"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 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 {
|
|
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
|
|
}
|
|
}
|
|
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
|
|
}
|
|
host, _, err := net.SplitHostPort(listener.Listen)
|
|
if err != nil {
|
|
return fmt.Errorf("validate %s listen: %w", name, 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 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 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 "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 "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 := requireNonNegative(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 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 := 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.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()
|
|
}
|