Compare commits

...

1 Commits

Author SHA1 Message Date
Andrii Chubatiuk
d5d994b436 vmauth: add backend_load_balancing_policy 2026-07-23 13:04:47 +03:00
8 changed files with 1253 additions and 206 deletions

View File

@@ -73,22 +73,15 @@ type UserInfo struct {
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
URLPrefix *URLPrefix `yaml:"url_prefix,omitempty"`
DiscoverBackendIPs *bool `yaml:"discover_backend_ips,omitempty"`
URLMaps []URLMap `yaml:"url_map,omitempty"`
DumpRequestOnErrors bool `yaml:"dump_request_on_errors,omitempty"`
HeadersConf HeadersConf `yaml:",inline"`
MaxConcurrentRequests int `yaml:"max_concurrent_requests,omitempty"`
DefaultURL *URLPrefix `yaml:"default_url,omitempty"`
RetryStatusCodes []int `yaml:"retry_status_codes,omitempty"`
LoadBalancingPolicy string `yaml:"load_balancing_policy,omitempty"`
MergeQueryArgs []string `yaml:"merge_query_args,omitempty"`
DropSrcPathPrefixParts *int `yaml:"drop_src_path_prefix_parts,omitempty"`
TLSCAFile string `yaml:"tls_ca_file,omitempty"`
TLSCertFile string `yaml:"tls_cert_file,omitempty"`
TLSKeyFile string `yaml:"tls_key_file,omitempty"`
TLSServerName string `yaml:"tls_server_name,omitempty"`
TLSInsecureSkipVerify *bool `yaml:"tls_insecure_skip_verify,omitempty"`
URLPrefix *URLPrefix `yaml:"url_prefix,omitempty"`
URLMaps []URLMap `yaml:"url_map,omitempty"`
DumpRequestOnErrors bool `yaml:"dump_request_on_errors,omitempty"`
HeadersConf HeadersConf `yaml:",inline"`
BackendSettings BackendSettings `yaml:",inline"`
DefaultURL *URLPrefix `yaml:"default_url,omitempty"`
RetryStatusCodes []int `yaml:"retry_status_codes,omitempty"`
MergeQueryArgs []string `yaml:"merge_query_args,omitempty"`
DropSrcPathPrefixParts *int `yaml:"drop_src_path_prefix_parts,omitempty"`
MetricLabels map[string]string `yaml:"metric_labels,omitempty"`
@@ -154,6 +147,19 @@ type HeadersConf struct {
hasAnyPlaceHolders bool
}
// BackendSettings holds settings shared between UserInfo and BackendGroupConfig for controlling
// how vmauth selects and connects to backends.
type BackendSettings struct {
LoadBalancingPolicy string `yaml:"load_balancing_policy,omitempty"`
DiscoverBackendIPs *bool `yaml:"discover_backend_ips,omitempty"`
MaxConcurrentRequests int `yaml:"max_concurrent_requests,omitempty"`
TLSCAFile string `yaml:"tls_ca_file,omitempty"`
TLSCertFile string `yaml:"tls_cert_file,omitempty"`
TLSKeyFile string `yaml:"tls_key_file,omitempty"`
TLSServerName string `yaml:"tls_server_name,omitempty"`
TLSInsecureSkipVerify *bool `yaml:"tls_insecure_skip_verify,omitempty"`
}
func (ui *UserInfo) beginConcurrencyLimit(ctx context.Context) error {
select {
case ui.concurrencyLimitCh <- struct{}{}:
@@ -185,7 +191,7 @@ func (ui *UserInfo) endConcurrencyLimit() {
}
func (ui *UserInfo) getMaxConcurrentRequests() int {
mcr := ui.MaxConcurrentRequests
mcr := ui.BackendSettings.MaxConcurrentRequests
if mcr <= 0 {
mcr = *maxConcurrentPerUserRequests
}
@@ -340,20 +346,30 @@ type URLPrefix struct {
// how many request path prefix parts to drop before routing the request to backendURL
dropSrcPathPrefixParts int
// busOriginal contains the original list of backends specified in yaml config.
busOriginal []*url.URL
// busOriginal contains the original list of backend groups specified in yaml config.
busOriginal []*backendGroupSpec
// n is an atomic counter, which is used for balancing load among available backends.
n atomic.Uint32
// backendGroupCounters holds one atomic counter per busOriginal entry, used for balancing load.
backendGroupCounters []atomic.Uint32
// the list of backend urls
//
// the list can be dynamically updated if `discover_backend_ips` option is set.
bus atomic.Pointer[backendURLs]
// if this option is set, then backend ips for busOriginal are periodically re-discovered and put to bus.
// if this option is set by default, then backend ips for busOriginal are periodically re-discovered and put to bus.
//
// individual busOriginal entries may override this via their own discover_backend_ips setting.
discoverBackendIPs bool
// hasAnyBackendDiscovery is true if discovery is effectively enabled for at least one busOriginal entry.
// It is computed once in sanitizeAndInitialize, so discoverBackendAddrsIfNeeded can cheaply skip
// the whole discovery machinery on the common no-discovery path.
hasAnyBackendDiscovery bool
// The next deadline for DNS-based discovery of backend IPs
nextDiscoveryDeadline atomic.Uint64
@@ -361,21 +377,208 @@ type URLPrefix struct {
vOriginal any
}
func (up *URLPrefix) setLoadBalancingPolicy(loadBalancingPolicy string) error {
switch loadBalancingPolicy {
case "", // empty string is equivalent to least_loaded
"least_loaded",
"first_available":
up.loadBalancingPolicy = loadBalancingPolicy
return nil
// backendGroupSpec represents a single parsed `url_prefix` list item.
//
// It is built either from a plain url string (no overrides, inherits everything from the
// enclosing scope) or from a BackendGroupConfig mapping.
type backendGroupSpec struct {
// name identifies this group in metrics. Falls back to its ordinal position in url_prefix when empty.
name string
urls []*url.URL
// per-group overrides; zero values mean "inherit from the enclosing url_prefix / user / url_map scope".
loadBalancingPolicy string
discoverBackendIPs *bool
maxConcurrentRequests int
tlsCAFile string
tlsCertFile string
tlsKeyFile string
tlsServerName string
tlsInsecureSkipVerify *bool
}
func (spec *backendGroupSpec) hasTLSOverride() bool {
return spec.tlsCAFile != "" || spec.tlsCertFile != "" || spec.tlsKeyFile != "" ||
spec.tlsServerName != "" || spec.tlsInsecureSkipVerify != nil
}
// BackendGroupConfig represents a `url_prefix` list item specified as a YAML mapping instead of a plain string.
//
// It lets a group of backend urls override load_balancing_policy, discover_backend_ips,
// max_concurrent_requests and tls_* settings, which are otherwise inherited from the enclosing
// `user` / `url_map` scope. See https://docs.victoriametrics.com/victoriametrics/vmauth/#load-balancing
type BackendGroupConfig struct {
// Name optionally identifies this backend group in metrics. Falls back to its ordinal
// position in url_prefix when not set.
Name string `yaml:"name,omitempty"`
URLPrefix stringOrSlice `yaml:"url_prefix"`
BackendSettings BackendSettings `yaml:",inline"`
}
// stringOrSlice unmarshals a YAML value that is either a single string or a list of strings.
type stringOrSlice []string
func (s *stringOrSlice) UnmarshalYAML(f func(any) error) error {
var v any
if err := f(&v); err != nil {
return err
}
urls, err := parseStringOrSliceValue(v)
if err != nil {
return fmt.Errorf("cannot unmarshal `url_prefix`: %w", err)
}
*s = urls
return nil
}
func parseStringOrSliceValue(v any) ([]string, error) {
switch x := v.(type) {
case string:
return []string{x}, nil
case []any:
if len(x) == 0 {
return nil, fmt.Errorf("must contain at least a single url")
}
us := make([]string, len(x))
for i, xx := range x {
s, ok := xx.(string)
if !ok {
return nil, fmt.Errorf("must contain array of strings; got %T", xx)
}
us[i] = s
}
return us, nil
default:
return fmt.Errorf("unexpected load_balancing_policy: %q; want least_loaded or first_available", loadBalancingPolicy)
return nil, fmt.Errorf("unexpected type: %T; want string or []string", v)
}
}
func validateLoadBalancingPolicyValue(policy string) error {
switch policy {
case "", // empty string is equivalent to least_loaded
"least_loaded",
"first_available":
return nil
default:
return fmt.Errorf("unexpected load_balancing_policy: %q; want least_loaded or first_available", policy)
}
}
func (up *URLPrefix) setLoadBalancingPolicy(loadBalancingPolicy string) error {
if err := validateLoadBalancingPolicyValue(loadBalancingPolicy); err != nil {
return err
}
up.loadBalancingPolicy = loadBalancingPolicy
return nil
}
// backendUserSettings holds the resolved user-level settings needed for building backendURLGroups.
//
// It lets URLPrefix.sanitizeAndInitialize apply per-group overrides (load_balancing_policy, tls_*,
// max_concurrent_requests) on top of the settings inherited from the enclosing user.
type backendUserSettings struct {
tlsCAFile string
tlsCertFile string
tlsKeyFile string
tlsServerName string
tlsInsecureSkipVerify *bool
ms *metrics.Set
metricLabels string
}
func backendGroupMetricLabels(userLabels, groupID string) string {
label := fmt.Sprintf(`backend_group=%q`, groupID)
if userLabels == "" {
return "{" + label + "}"
}
return userLabels[:len(userLabels)-1] + "," + label + "}"
}
type backendURLs struct {
bhc backendHealthCheck
bhc backendHealthCheck
n *atomic.Uint32
groups []*backendURLGroup
}
// backendURLGroup holds the backend urls discovered for a single busOriginal entry.
type backendURLGroup struct {
// n is an atomic counter, which is used for balancing load among bus.
//
// It points into URLPrefix.backendGroupCounters, so it survives across the
// group being recreated by backend IP rediscovery.
n *atomic.Uint32
bus []*backendURL
loadBalancingPolicy string
// rt is a per-group HTTP RoundTripper. nil means inherit the enclosing user's RoundTripper.
rt http.RoundTripper
// concurrencyLimitCh is a per-group concurrency limiter. nil means no group-level limit is configured.
concurrencyLimitCh chan struct{}
concurrencyLimitReached *metrics.Counter
}
func (g *backendURLGroup) isBroken() bool {
for _, bu := range g.bus {
if !bu.isBroken() {
return false
}
}
return true
}
func (g *backendURLGroup) isAtConcurrencyLimit() bool {
return g.concurrencyLimitCh != nil && len(g.concurrencyLimitCh) >= cap(g.concurrencyLimitCh)
}
func (g *backendURLGroup) isUnavailable() bool {
return g.isBroken() || g.isAtConcurrencyLimit()
}
func (g *backendURLGroup) minConcurrentRequests() int32 {
minReqs := int32(math.MaxInt32)
for _, bu := range g.bus {
if bu.isBroken() {
continue
}
if n := bu.concurrentRequests.Load(); n < minReqs {
minReqs = n
}
}
return minReqs
}
func (g *backendURLGroup) getBackendURL() *backendURL {
if g.loadBalancingPolicy == "first_available" {
return g.getFirstAvailable()
}
return g.getLeastLoaded()
}
func (g *backendURLGroup) beginConcurrencyLimit() bool {
if g.concurrencyLimitCh == nil {
return true
}
select {
case g.concurrencyLimitCh <- struct{}{}:
return true
default:
if g.concurrencyLimitReached != nil {
g.concurrencyLimitReached.Inc()
}
return false
}
}
func (g *backendURLGroup) endConcurrencyLimit() {
if g.concurrencyLimitCh == nil {
return
}
<-g.concurrencyLimitCh
}
type backendHealthCheck struct {
@@ -404,22 +607,34 @@ func (bhc *backendHealthCheck) stop() {
bhc.wg.Wait()
}
func newBackendURLs() *backendURLs {
func newBackendURLs(n *atomic.Uint32) *backendURLs {
ctx, cancel := context.WithCancel(context.Background())
return &backendURLs{
bhc: backendHealthCheck{
ctx: ctx,
cancel: cancel,
},
n: n,
}
}
func (bus *backendURLs) add(u *url.URL) {
bus.bus = append(bus.bus, &backendURL{
url: u,
bhc: &bus.bhc,
hasPlaceHolders: hasAnyPlaceholders(u),
})
// addGroup appends a new backendURLGroup to bus, containing a backendURL for every url in urls,
// and returns the newly created group so the caller can apply per-group settings to it.
func (bus *backendURLs) addGroup(urls []*url.URL, n *atomic.Uint32) *backendURLGroup {
g := &backendURLGroup{
n: n,
bus: make([]*backendURL, len(urls)),
}
for i, u := range urls {
g.bus[i] = &backendURL{
url: u,
bhc: &bus.bhc,
hasPlaceHolders: hasAnyPlaceholders(u),
group: g,
}
}
bus.groups = append(bus.groups, g)
return g
}
func (bus *backendURLs) stopHealthChecks() {
@@ -436,6 +651,8 @@ type backendURL struct {
url *url.URL
hasPlaceHolders bool
group *backendURLGroup
}
func (bu *backendURL) isBroken() bool {
@@ -495,7 +712,11 @@ func (bu *backendURL) put() {
func (up *URLPrefix) getBackendsCount() int {
bus := up.bus.Load()
return len(bus.bus)
n := 0
for _, g := range bus.groups {
n += len(g.bus)
}
return n
}
// getBackendURL returns the backendURL depending on the load balance policy.
@@ -507,19 +728,34 @@ func (up *URLPrefix) getBackendURL() *backendURL {
up.discoverBackendAddrsIfNeeded()
bus := up.bus.Load()
if len(bus.bus) == 0 {
if len(bus.groups) == 0 {
return nil
}
var g *backendURLGroup
if up.loadBalancingPolicy == "first_available" {
return getFirstAvailableBackendURL(bus.bus)
g = bus.getFirstAvailable()
} else {
g = bus.getLeastLoaded()
}
return getLeastLoadedBackendURL(bus.bus, &up.n)
if len(g.bus) == 0 {
return nil
}
return g.getBackendURL()
}
// effectiveDiscoverBackendIPs returns whether backend IP discovery is enabled for spec,
// taking into account spec's own override of up.discoverBackendIPs.
func (up *URLPrefix) effectiveDiscoverBackendIPs(spec *backendGroupSpec) bool {
if spec.discoverBackendIPs != nil {
return *spec.discoverBackendIPs
}
return up.discoverBackendIPs
}
func (up *URLPrefix) discoverBackendAddrsIfNeeded() {
if !up.discoverBackendIPs {
// The discovery is disabled.
if !up.hasAnyBackendDiscovery {
return
}
@@ -540,66 +776,89 @@ func (up *URLPrefix) discoverBackendAddrsIfNeeded() {
return
}
// Discover ips for all the backendURLs
// Discover ips for all the backendURLs which need it.
ctx, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(intervalSec))
hostToAddrs := make(map[string][]string)
for _, bu := range up.busOriginal {
host := bu.Hostname()
port := bu.Port()
if hostToAddrs[host] != nil {
// ips for the given host have been already discovered
for _, spec := range up.busOriginal {
if !up.effectiveDiscoverBackendIPs(spec) {
continue
}
for _, bu := range spec.urls {
host := bu.Hostname()
port := bu.Port()
if hostToAddrs[host] != nil {
// ips for the given host have been already discovered
continue
}
var resolvedAddrs []string
if strings.HasPrefix(host, "srv+") {
// The host has the format 'srv+realhost'. Strip 'srv+' prefix before performing the lookup.
srvHost := strings.TrimPrefix(host, "srv+")
_, addrs, err := netutil.Resolver.LookupSRV(ctx, "", "", srvHost)
if err != nil {
logger.Warnf("cannot discover backend SRV records for %s: %s; use it literally", bu, err)
resolvedAddrs = []string{host}
} else {
resolvedAddrs = make([]string, len(addrs))
for i, addr := range addrs {
hostPort := port
if hostPort == "" && addr.Port > 0 {
hostPort = strconv.FormatUint(uint64(addr.Port), 10)
var resolvedAddrs []string
if strings.HasPrefix(host, "srv+") {
// The host has the format 'srv+realhost'. Strip 'srv+' prefix before performing the lookup.
srvHost := strings.TrimPrefix(host, "srv+")
_, addrs, err := netutil.Resolver.LookupSRV(ctx, "", "", srvHost)
if err != nil {
logger.Warnf("cannot discover backend SRV records for %s: %s; use it literally", bu, err)
resolvedAddrs = []string{host}
} else {
resolvedAddrs = make([]string, len(addrs))
for i, addr := range addrs {
hostPort := port
if hostPort == "" && addr.Port > 0 {
hostPort = strconv.FormatUint(uint64(addr.Port), 10)
}
resolvedAddrs[i] = net.JoinHostPort(addr.Target, hostPort)
}
resolvedAddrs[i] = net.JoinHostPort(addr.Target, hostPort)
}
}
} else {
addrs, err := netutil.Resolver.LookupIPAddr(ctx, host)
if err != nil {
logger.Warnf("cannot discover backend IPs for %s: %s; use it literally", bu, err)
resolvedAddrs = []string{host}
} else {
resolvedAddrs = make([]string, len(addrs))
for i, addr := range addrs {
resolvedAddrs[i] = net.JoinHostPort(addr.String(), port)
addrs, err := netutil.Resolver.LookupIPAddr(ctx, host)
if err != nil {
logger.Warnf("cannot discover backend IPs for %s: %s; use it literally", bu, err)
resolvedAddrs = []string{host}
} else {
resolvedAddrs = make([]string, len(addrs))
for i, addr := range addrs {
resolvedAddrs[i] = net.JoinHostPort(addr.String(), port)
}
}
}
// sort resolvedAddrs, so they could be compared below in areEqualBackendURLGroups()
sort.Strings(resolvedAddrs)
hostToAddrs[host] = resolvedAddrs
}
// sort resolvedAddrs, so they could be compared below in areEqualBackendURLs()
sort.Strings(resolvedAddrs)
hostToAddrs[host] = resolvedAddrs
}
cancel()
// generate new backendURLs for the resolved IPs
busNew := newBackendURLs()
for _, bu := range up.busOriginal {
host := bu.Hostname()
for _, addr := range hostToAddrs[host] {
buCopy := *bu
buCopy.Host = addr
busNew.add(&buCopy)
// generate new backendURLs for the resolved IPs, one group per busOriginal entry
oldGroups := up.bus.Load().groups
busNew := newBackendURLs(&up.n)
for i, spec := range up.busOriginal {
var urls []*url.URL
if up.effectiveDiscoverBackendIPs(spec) {
for _, bu := range spec.urls {
host := bu.Hostname()
for _, addr := range hostToAddrs[host] {
buCopy := *bu
buCopy.Host = addr
urls = append(urls, &buCopy)
}
}
} else {
urls = spec.urls
}
g := busNew.addGroup(urls, &up.backendGroupCounters[i])
if i < len(oldGroups) {
// Per-group settings are static for the lifetime of this URLPrefix - carry them over
// instead of recomputing, so a per-group RoundTripper / concurrency limiter isn't
// rebuilt (and in-flight concurrency accounting isn't lost) on every rediscovery.
g.loadBalancingPolicy = oldGroups[i].loadBalancingPolicy
g.rt = oldGroups[i].rt
g.concurrencyLimitCh = oldGroups[i].concurrencyLimitCh
g.concurrencyLimitReached = oldGroups[i].concurrencyLimitReached
}
}
bus := up.bus.Load()
if areEqualBackendURLs(bus.bus, busNew.bus) {
if areEqualBackendURLGroups(bus.groups, busNew.groups) {
return
}
@@ -608,6 +867,18 @@ func (up *URLPrefix) discoverBackendAddrsIfNeeded() {
bus.stopHealthChecks()
}
func areEqualBackendURLGroups(a, b []*backendURLGroup) bool {
if len(a) != len(b) {
return false
}
for i, g := range a {
if !areEqualBackendURLs(g.bus, b[i].bus) {
return false
}
}
return true
}
func areEqualBackendURLs(a, b []*backendURL) bool {
if len(a) != len(b) {
return false
@@ -621,11 +892,12 @@ func areEqualBackendURLs(a, b []*backendURL) bool {
return true
}
// getFirstAvailableBackendURL returns the first available backendURL, which isn't broken.
// If all backendURLs are broken, then returns the first backendURL.
// getFirstAvailable returns the first available backendURL in g, which isn't broken.
// If all backendURLs in g are broken, then returns the first one.
//
// backendURL.put() must be called on the returned backendURL after the request is complete.
func getFirstAvailableBackendURL(bus []*backendURL) *backendURL {
func (g *backendURLGroup) getFirstAvailable() *backendURL {
bus := g.bus
bu := bus[0]
if !bu.isBroken() {
// Fast path - send the request to the first url.
@@ -648,11 +920,10 @@ func getFirstAvailableBackendURL(bus []*backendURL) *backendURL {
return bu
}
// getLeastLoadedBackendURL returns a non-broken backendURL with the lowest number of concurrent requests.
// If all backendURLs are broken, then returns the first backendURL.
//
// backendURL.put() must be called on the returned backendURL after the request is complete.
func getLeastLoadedBackendURL(bus []*backendURL, atomicCounter *atomic.Uint32) *backendURL {
func (g *backendURLGroup) getLeastLoaded() *backendURL {
bus := g.bus
atomicCounter := g.n
firstBu := bus[0]
if len(bus) == 1 {
firstBu.get()
@@ -704,6 +975,62 @@ func getLeastLoadedBackendURL(bus []*backendURL, atomicCounter *atomic.Uint32) *
return buMin
}
// getFirstAvailable returns the first backendURLGroup in bus, which has at least a single non-broken backend url.
// If all groups are fully broken, then returns the first one.
func (bus *backendURLs) getFirstAvailable() *backendURLGroup {
groups := bus.groups
g := groups[0]
if !g.isUnavailable() {
// Fast path - use the first group.
return g
}
// Slow path - the first group is temporarily unavailable. Fall back to the remaining groups.
for i := 1; i < len(groups); i++ {
if !groups[i].isUnavailable() {
return groups[i]
}
}
// All groups are unavailable, then return the first one, it could help increase the success rate of the requests.
return g
}
// getLeastLoaded returns a non-broken backendURLGroup in bus with the lowest number of concurrent requests
// among its non-broken backend urls. If all groups are broken, then returns the first one.
func (bus *backendURLs) getLeastLoaded() *backendURLGroup {
groups := bus.groups
atomicCounter := bus.n
firstGroup := groups[0]
if len(groups) == 1 {
return firstGroup
}
n := atomicCounter.Add(1) - 1
gMinIdx := n % uint32(len(groups))
minRequests := groups[gMinIdx].minConcurrentRequests()
for i := uint32(1); i < uint32(len(groups)); i++ {
idx := (n + i) % uint32(len(groups))
g := groups[idx]
if g.isUnavailable() {
continue
}
reqs := g.minConcurrentRequests()
if reqs < minRequests || groups[gMinIdx].isUnavailable() {
gMinIdx = idx
minRequests = reqs
}
}
gMin := groups[gMinIdx]
if gMin.isUnavailable() {
// If all groups are unavailable, then returns the first group.
return firstGroup
}
atomicCounter.CompareAndSwap(n+1, gMinIdx+1)
return gMin
}
// UnmarshalYAML unmarshals up from yaml.
func (up *URLPrefix) UnmarshalYAML(f func(any) error) error {
var v any
@@ -712,39 +1039,79 @@ func (up *URLPrefix) UnmarshalYAML(f func(any) error) error {
}
up.vOriginal = v
var urls []string
var items []any
switch x := v.(type) {
case string:
urls = []string{x}
items = []any{x}
case []any:
if len(x) == 0 {
return fmt.Errorf("`url_prefix` must contain at least a single url")
}
us := make([]string, len(x))
for i, xx := range x {
s, ok := xx.(string)
if !ok {
return fmt.Errorf("`url_prefix` must contain array of strings; got %T", xx)
}
us[i] = s
}
urls = us
items = x
default:
return fmt.Errorf("unexpected type for `url_prefix`: %T; want string or []string", v)
return fmt.Errorf("unexpected type for `url_prefix`: %T; want string, []string or a list containing backend group mappings", v)
}
bus := make([]*url.URL, len(urls))
for i, u := range urls {
pu, err := url.Parse(u)
specs := make([]*backendGroupSpec, len(items))
for i, item := range items {
spec, err := parseBackendGroupSpecItem(item)
if err != nil {
return fmt.Errorf("cannot unmarshal %q into url: %w", u, err)
return fmt.Errorf("cannot unmarshal `url_prefix` item #%d: %w", i+1, err)
}
bus[i] = pu
specs[i] = spec
}
up.busOriginal = bus
up.busOriginal = specs
return nil
}
func parseBackendGroupSpecItem(item any) (*backendGroupSpec, error) {
switch x := item.(type) {
case string:
pu, err := url.Parse(x)
if err != nil {
return nil, fmt.Errorf("cannot unmarshal %q into url: %w", x, err)
}
return &backendGroupSpec{urls: []*url.URL{pu}}, nil
case map[interface{}]interface{}:
data, err := yaml.Marshal(x)
if err != nil {
return nil, fmt.Errorf("cannot re-marshal backend group mapping: %w", err)
}
var bgc BackendGroupConfig
if err := yaml.UnmarshalStrict(data, &bgc); err != nil {
return nil, fmt.Errorf("cannot unmarshal backend group mapping: %w", err)
}
if len(bgc.URLPrefix) == 0 {
return nil, fmt.Errorf("missing `url_prefix` in backend group mapping")
}
if err := validateLoadBalancingPolicyValue(bgc.BackendSettings.LoadBalancingPolicy); err != nil {
return nil, err
}
urls := make([]*url.URL, len(bgc.URLPrefix))
for i, u := range bgc.URLPrefix {
pu, err := url.Parse(u)
if err != nil {
return nil, fmt.Errorf("cannot unmarshal %q into url: %w", u, err)
}
urls[i] = pu
}
return &backendGroupSpec{
name: bgc.Name,
urls: urls,
loadBalancingPolicy: bgc.BackendSettings.LoadBalancingPolicy,
discoverBackendIPs: bgc.BackendSettings.DiscoverBackendIPs,
maxConcurrentRequests: bgc.BackendSettings.MaxConcurrentRequests,
tlsCAFile: bgc.BackendSettings.TLSCAFile,
tlsCertFile: bgc.BackendSettings.TLSCertFile,
tlsKeyFile: bgc.BackendSettings.TLSKeyFile,
tlsServerName: bgc.BackendSettings.TLSServerName,
tlsInsecureSkipVerify: bgc.BackendSettings.TLSInsecureSkipVerify,
}, nil
default:
return nil, fmt.Errorf("unexpected type for `url_prefix` item: %T; want a string or a mapping with url_prefix/load_balancing_policy/discover_backend_ips/etc", item)
}
}
// MarshalYAML marshals up to yaml.
func (up *URLPrefix) MarshalYAML() (any, error) {
return up.vOriginal, nil
@@ -997,7 +1364,7 @@ func parseAuthConfig(data []byte) (*AuthConfig, error) {
}
if ui.hasAnyURLs() {
if err := ui.initURLs(); err != nil {
if err := ui.initURLs(ac.ms); err != nil {
return nil, err
}
}
@@ -1020,7 +1387,7 @@ func parseAuthConfig(data []byte) (*AuthConfig, error) {
return float64(len(ui.concurrencyLimitCh))
})
rt, err := newRoundTripper(ui.TLSCAFile, ui.TLSCertFile, ui.TLSKeyFile, ui.TLSServerName, ui.TLSInsecureSkipVerify)
rt, err := newRoundTripper(ui.BackendSettings)
if err != nil {
return nil, fmt.Errorf("cannot initialize HTTP RoundTripper: %w", err)
}
@@ -1059,7 +1426,7 @@ func parseAuthConfigUsers(ac *AuthConfig) (map[string]*UserInfo, error) {
if err := parseJWTPlaceholdersForUserInfo(ui, false); err != nil {
return nil, err
}
if err := ui.initURLs(); err != nil {
if err := ui.initURLs(ac.ms); err != nil {
return nil, err
}
@@ -1082,7 +1449,7 @@ func parseAuthConfigUsers(ac *AuthConfig) (map[string]*UserInfo, error) {
return float64(len(ui.concurrencyLimitCh))
})
rt, err := newRoundTripper(ui.TLSCAFile, ui.TLSCertFile, ui.TLSKeyFile, ui.TLSServerName, ui.TLSInsecureSkipVerify)
rt, err := newRoundTripper(ui.BackendSettings)
if err != nil {
return nil, fmt.Errorf("cannot initialize HTTP RoundTripper: %w", err)
}
@@ -1118,7 +1485,7 @@ func (ui *UserInfo) getMetricLabels() (string, error) {
return labelsStr, nil
}
func (ui *UserInfo) initURLs() error {
func (ui *UserInfo) initURLs(ms *metrics.Set) error {
retryStatusCodes := defaultRetryStatusCodes.Values()
loadBalancingPolicy := *defaultLoadBalancingPolicy
mergeQueryArgs := *defaultMergeQueryArgs
@@ -1127,8 +1494,8 @@ func (ui *UserInfo) initURLs() error {
if ui.RetryStatusCodes != nil {
retryStatusCodes = ui.RetryStatusCodes
}
if ui.LoadBalancingPolicy != "" {
loadBalancingPolicy = ui.LoadBalancingPolicy
if ui.BackendSettings.LoadBalancingPolicy != "" {
loadBalancingPolicy = ui.BackendSettings.LoadBalancingPolicy
}
if len(ui.MergeQueryArgs) != 0 {
mergeQueryArgs = ui.MergeQueryArgs
@@ -1136,15 +1503,26 @@ func (ui *UserInfo) initURLs() error {
if ui.DropSrcPathPrefixParts != nil {
dropSrcPathPrefixParts = *ui.DropSrcPathPrefixParts
}
if ui.DiscoverBackendIPs != nil {
discoverBackendIPs = *ui.DiscoverBackendIPs
if ui.BackendSettings.DiscoverBackendIPs != nil {
discoverBackendIPs = *ui.BackendSettings.DiscoverBackendIPs
}
metricLabels, err := ui.getMetricLabels()
if err != nil {
return err
}
userSettings := backendUserSettings{
tlsCAFile: ui.BackendSettings.TLSCAFile,
tlsCertFile: ui.BackendSettings.TLSCertFile,
tlsKeyFile: ui.BackendSettings.TLSKeyFile,
tlsServerName: ui.BackendSettings.TLSServerName,
tlsInsecureSkipVerify: ui.BackendSettings.TLSInsecureSkipVerify,
ms: ms,
metricLabels: metricLabels,
}
up := ui.URLPrefix
if up != nil {
if err := up.sanitizeAndInitialize(); err != nil {
return err
}
up.retryStatusCodes = retryStatusCodes
up.dropSrcPathPrefixParts = dropSrcPathPrefixParts
up.discoverBackendIPs = discoverBackendIPs
@@ -1152,9 +1530,12 @@ func (ui *UserInfo) initURLs() error {
return err
}
up.mergeQueryArgs = mergeQueryArgs
if err := up.sanitizeAndInitialize(userSettings); err != nil {
return err
}
}
if ui.DefaultURL != nil {
if err := ui.DefaultURL.sanitizeAndInitialize(); err != nil {
if err := ui.DefaultURL.sanitizeAndInitialize(userSettings); err != nil {
return err
}
}
@@ -1166,9 +1547,6 @@ func (ui *UserInfo) initURLs() error {
if e.URLPrefix == nil {
return fmt.Errorf("missing `url_prefix` in `url_map`")
}
if err := e.URLPrefix.sanitizeAndInitialize(); err != nil {
return err
}
rscs := retryStatusCodes
lbp := loadBalancingPolicy
mqa := mergeQueryArgs
@@ -1196,6 +1574,9 @@ func (ui *UserInfo) initURLs() error {
e.URLPrefix.mergeQueryArgs = mqa
e.URLPrefix.dropSrcPathPrefixParts = dsp
e.URLPrefix.discoverBackendIPs = dbd
if err := e.URLPrefix.sanitizeAndInitialize(userSettings); err != nil {
return err
}
}
if len(ui.URLMaps) == 0 && ui.URLPrefix == nil {
return fmt.Errorf("missing `url_prefix` or `url_map`")
@@ -1298,19 +1679,89 @@ func getAuthTokensFromRequest(r *http.Request) []string {
return ats
}
func (up *URLPrefix) sanitizeAndInitialize() error {
for i, bu := range up.busOriginal {
puNew, err := sanitizeURLPrefix(bu)
if err != nil {
return err
// sanitizeAndInitialize validates up.busOriginal and (re)initializes up.bus from it,
// applying per-group overrides on top of the settings inherited from userSettings.
func (up *URLPrefix) sanitizeAndInitialize(userSettings backendUserSettings) error {
for _, spec := range up.busOriginal {
for i, bu := range spec.urls {
puNew, err := sanitizeURLPrefix(bu)
if err != nil {
return err
}
spec.urls[i] = puNew
}
up.busOriginal[i] = puNew
}
// Initialize up.bus
bus := newBackendURLs()
for _, bu := range up.busOriginal {
bus.add(bu)
up.backendGroupCounters = make([]atomic.Uint32, len(up.busOriginal))
up.hasAnyBackendDiscovery = false
for _, spec := range up.busOriginal {
if up.effectiveDiscoverBackendIPs(spec) {
up.hasAnyBackendDiscovery = true
break
}
}
// Initialize up.bus with a single group per busOriginal entry.
bus := newBackendURLs(&up.n)
for i, spec := range up.busOriginal {
g := bus.addGroup(spec.urls, &up.backendGroupCounters[i])
lbp := spec.loadBalancingPolicy
if lbp == "" {
lbp = up.loadBalancingPolicy
}
g.loadBalancingPolicy = lbp
if spec.hasTLSOverride() {
bs := BackendSettings{
TLSCAFile: spec.tlsCAFile,
TLSCertFile: spec.tlsCertFile,
TLSKeyFile: spec.tlsKeyFile,
TLSServerName: spec.tlsServerName,
TLSInsecureSkipVerify: spec.tlsInsecureSkipVerify,
}
if bs.TLSCAFile == "" {
bs.TLSCAFile = userSettings.tlsCAFile
}
if bs.TLSCertFile == "" {
bs.TLSCertFile = userSettings.tlsCertFile
}
if bs.TLSKeyFile == "" {
bs.TLSKeyFile = userSettings.tlsKeyFile
}
if bs.TLSServerName == "" {
bs.TLSServerName = userSettings.tlsServerName
}
if bs.TLSInsecureSkipVerify == nil {
bs.TLSInsecureSkipVerify = userSettings.tlsInsecureSkipVerify
}
rt, err := newRoundTripper(bs)
if err != nil {
return fmt.Errorf("cannot initialize HTTP RoundTripper for a backend group at `url_prefix` item #%d: %w", i+1, err)
}
g.rt = rt
}
if spec.maxConcurrentRequests > 0 {
g.concurrencyLimitCh = make(chan struct{}, spec.maxConcurrentRequests)
if userSettings.ms != nil {
groupID := spec.name
if groupID == "" {
groupID = strconv.Itoa(i)
}
groupLabels := backendGroupMetricLabels(userSettings.metricLabels, groupID)
g.concurrencyLimitReached = userSettings.ms.GetOrCreateCounter(`vmauth_backend_group_concurrent_requests_limit_reached_total` + groupLabels)
ch := g.concurrencyLimitCh
_ = userSettings.ms.GetOrCreateGauge(`vmauth_backend_group_concurrent_requests_capacity`+groupLabels, func() float64 {
return float64(cap(ch))
})
_ = userSettings.ms.GetOrCreateGauge(`vmauth_backend_group_concurrent_requests_current`+groupLabels, func() float64 {
return float64(len(ch))
})
}
}
}
up.bus.Store(bus)

View File

@@ -2,11 +2,13 @@ package main
import (
"bytes"
"context"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
@@ -365,11 +367,13 @@ users:
tls_insecure_skip_verify: true
`, map[string]*UserInfo{
getHTTPAuthBasicToken("foo", "bar"): {
Username: "foo",
Password: "bar",
URLPrefix: mustParseURL("http://aaa:343/bbb"),
MaxConcurrentRequests: 5,
TLSInsecureSkipVerify: &insecureSkipVerifyTrue,
Username: "foo",
Password: "bar",
URLPrefix: mustParseURL("http://aaa:343/bbb"),
BackendSettings: BackendSettings{
MaxConcurrentRequests: 5,
TLSInsecureSkipVerify: &insecureSkipVerifyTrue,
},
},
}, nil)
@@ -386,14 +390,16 @@ users:
tls_key_file: "foo/foo"
`, map[string]*UserInfo{
getHTTPAuthToken("foo"): {
AuthToken: "foo",
URLPrefix: mustParseURL("https://aaa:343/bbb"),
MaxConcurrentRequests: 5,
TLSInsecureSkipVerify: &insecureSkipVerifyTrue,
TLSServerName: "foo.bar",
TLSCAFile: "foo/bar",
TLSCertFile: "foo/baz",
TLSKeyFile: "foo/foo",
AuthToken: "foo",
URLPrefix: mustParseURL("https://aaa:343/bbb"),
BackendSettings: BackendSettings{
MaxConcurrentRequests: 5,
TLSInsecureSkipVerify: &insecureSkipVerifyTrue,
TLSServerName: "foo.bar",
TLSCAFile: "foo/bar",
TLSCertFile: "foo/baz",
TLSKeyFile: "foo/foo",
},
},
}, nil)
@@ -421,12 +427,14 @@ users:
"http://node1:343/bbb",
"http://srv+node2:343/bbb",
}),
TLSInsecureSkipVerify: &insecureSkipVerifyFalse,
BackendSettings: BackendSettings{
TLSInsecureSkipVerify: &insecureSkipVerifyFalse,
LoadBalancingPolicy: "first_available",
DiscoverBackendIPs: &discoverBackendIPsTrue,
},
RetryStatusCodes: []int{500, 501},
LoadBalancingPolicy: "first_available",
MergeQueryArgs: []string{"foo", "bar"},
DropSrcPathPrefixParts: new(1),
DiscoverBackendIPs: &discoverBackendIPsTrue,
},
}, nil)
@@ -736,11 +744,11 @@ unauthorized_user:
}
ui := m[getHTTPAuthBasicToken("foo", "bar")]
if !isSetBool(ui.TLSInsecureSkipVerify, true) {
if !isSetBool(ui.BackendSettings.TLSInsecureSkipVerify, true) {
t.Fatalf("unexpected TLSInsecureSkipVerify value for user foo")
}
if !isSetBool(ac.UnauthorizedUser.TLSInsecureSkipVerify, false) {
if !isSetBool(ac.UnauthorizedUser.BackendSettings.TLSInsecureSkipVerify, false) {
t.Fatalf("unexpected TLSInsecureSkipVerify value for unauthorized_user")
}
}
@@ -841,7 +849,7 @@ func TestGetLeastLoadedBackendURL(t *testing.T) {
up.loadBalancingPolicy = "least_loaded"
pbus := up.bus.Load()
bus := pbus.bus
bus := pbus.groups[0].bus
fn := func(ns ...int) {
t.Helper()
@@ -913,7 +921,7 @@ func TestBrokenBackend(t *testing.T) {
})
up.loadBalancingPolicy = "least_loaded"
pbus := up.bus.Load()
bus := pbus.bus
bus := pbus.groups[0].bus
// explicitly mark one of the backends as broken
bus[1].setBroken()
@@ -932,11 +940,12 @@ func TestDiscoverBackendIPsWithIPV6(t *testing.T) {
t.Helper()
up := mustParseURL(actualUrl)
up.discoverBackendIPs = true
up.hasAnyBackendDiscovery = true
up.loadBalancingPolicy = "least_loaded"
up.discoverBackendAddrsIfNeeded()
pbus := up.bus.Load()
bus := pbus.bus
bus := pbus.groups[0].bus
if len(bus) != 1 {
t.Fatalf("expected url list to be of size 1; got %d instead", len(bus))
@@ -996,6 +1005,114 @@ func TestDiscoverBackendIPsWithIPV6(t *testing.T) {
}
func TestAreEqualBackendURLGroups(t *testing.T) {
newGroup := func(hosts ...string) *backendURLGroup {
g := &backendURLGroup{}
for _, h := range hosts {
g.bus = append(g.bus, &backendURL{url: &url.URL{Host: h}})
}
return g
}
f := func(a, b []*backendURLGroup, expected bool) {
t.Helper()
if got := areEqualBackendURLGroups(a, b); got != expected {
t.Fatalf("unexpected result; got %v; want %v", got, expected)
}
}
// identical grouping
f(
[]*backendURLGroup{newGroup("10.0.0.1", "10.0.0.2"), newGroup("10.0.0.3")},
[]*backendURLGroup{newGroup("10.0.0.1", "10.0.0.2"), newGroup("10.0.0.3")},
true,
)
// different number of groups
f(
[]*backendURLGroup{newGroup("10.0.0.1")},
[]*backendURLGroup{newGroup("10.0.0.1"), newGroup("10.0.0.2")},
false,
)
// the flattened address sequence is unchanged, but an address moved across the group boundary
f(
[]*backendURLGroup{newGroup("10.0.0.1", "10.0.0.2"), newGroup("10.0.0.3")},
[]*backendURLGroup{newGroup("10.0.0.1"), newGroup("10.0.0.2", "10.0.0.3")},
false,
)
// genuinely different content
f(
[]*backendURLGroup{newGroup("10.0.0.1")},
[]*backendURLGroup{newGroup("10.0.0.9")},
false,
)
}
func TestDiscoverBackendAddrsIfNeededDetectsGroupBoundaryShift(t *testing.T) {
customResolver := &fakeResolver{
Resolver: &net.Resolver{},
lookupIPAddrResults: map[string][]net.IPAddr{
"zonea": {
{IP: net.ParseIP("10.0.0.1")},
{IP: net.ParseIP("10.0.0.2")},
},
"zoneb": {
{IP: net.ParseIP("10.0.0.3")},
},
},
}
origResolver := netutil.Resolver
netutil.Resolver = customResolver
defer func() {
netutil.Resolver = origResolver
}()
up := &URLPrefix{}
up.busOriginal = []*backendGroupSpec{
{urls: []*url.URL{{Scheme: "http", Host: "zonea"}}},
{urls: []*url.URL{{Scheme: "http", Host: "zoneb"}}},
}
up.backendGroupCounters = make([]atomic.Uint32, 2)
bus0 := newBackendURLs(&up.n)
bus0.addGroup(up.busOriginal[0].urls, &up.backendGroupCounters[0])
bus0.addGroup(up.busOriginal[1].urls, &up.backendGroupCounters[1])
up.bus.Store(bus0)
up.discoverBackendIPs = true
up.hasAnyBackendDiscovery = true
up.discoverBackendAddrsIfNeeded()
bus := up.bus.Load()
if len(bus.groups) != 2 || len(bus.groups[0].bus) != 2 || len(bus.groups[1].bus) != 1 {
t.Fatalf("unexpected initial grouping: zonea=%d zoneb=%d", len(bus.groups[0].bus), len(bus.groups[1].bus))
}
// Simulate 10.0.0.2 moving from zonea to zoneb. The flattened address sequence
// stays [10.0.0.1, 10.0.0.2, 10.0.0.3], but the group boundaries shift, so the
// rediscovery must still be picked up.
customResolver.lookupIPAddrResults["zonea"] = []net.IPAddr{
{IP: net.ParseIP("10.0.0.1")},
}
customResolver.lookupIPAddrResults["zoneb"] = []net.IPAddr{
{IP: net.ParseIP("10.0.0.2")},
{IP: net.ParseIP("10.0.0.3")},
}
up.nextDiscoveryDeadline.Store(0)
up.discoverBackendAddrsIfNeeded()
bus = up.bus.Load()
if len(bus.groups) != 2 {
t.Fatalf("unexpected groups count after rediscovery: %d", len(bus.groups))
}
if len(bus.groups[0].bus) != 1 || bus.groups[0].bus[0].url.Host != "10.0.0.1:" {
t.Fatalf("unexpected zonea group after rediscovery: %v", bus.groups[0].bus)
}
if len(bus.groups[1].bus) != 2 {
t.Fatalf("unexpected zoneb group size after rediscovery; got %d; want 2", len(bus.groups[1].bus))
}
}
func TestLogRequest(t *testing.T) {
ui := &UserInfo{AccessLog: &AccessLog{}}
@@ -1041,7 +1158,8 @@ func TestGetFirstAvailableBackend(t *testing.T) {
}
bus[i].broken.Store(broken[i])
}
bu := getFirstAvailableBackendURL(bus)
g := &backendURLGroup{bus: bus}
bu := g.getFirstAvailable()
if bu == nil {
t.Fatalf("unexpected nil backend")
}
@@ -1058,6 +1176,355 @@ func TestGetFirstAvailableBackend(t *testing.T) {
}
func newTestGroup(broken ...bool) *backendURLGroup {
ctx, cancel := context.WithCancel(context.Background())
bhc := &backendHealthCheck{
ctx: ctx,
cancel: cancel,
}
g := &backendURLGroup{
bus: make([]*backendURL, len(broken)),
}
for i, b := range broken {
g.bus[i] = &backendURL{
url: &url.URL{Host: fmt.Sprintf("target-%d", i)},
bhc: bhc,
}
g.bus[i].broken.Store(b)
}
return g
}
func TestGetFirstAvailableGroup(t *testing.T) {
// the first group is available
bus := &backendURLs{groups: []*backendURLGroup{newTestGroup(false), newTestGroup(false)}}
if g := bus.getFirstAvailable(); g != bus.groups[0] {
t.Fatalf("expecting the first group to be returned")
}
// the first group is fully broken - falls back to the second one
bus = &backendURLs{groups: []*backendURLGroup{newTestGroup(true), newTestGroup(false)}}
if g := bus.getFirstAvailable(); g != bus.groups[1] {
t.Fatalf("expecting the second group to be returned")
}
// the first group has a non-broken target - it is still considered available
bus = &backendURLs{groups: []*backendURLGroup{newTestGroup(true, false), newTestGroup(false)}}
if g := bus.getFirstAvailable(); g != bus.groups[0] {
t.Fatalf("expecting the first group to be returned, since it has a non-broken target")
}
// all groups are fully broken - the first one is returned
bus = &backendURLs{groups: []*backendURLGroup{newTestGroup(true), newTestGroup(true)}}
if g := bus.getFirstAvailable(); g != bus.groups[0] {
t.Fatalf("expecting the first group to be returned when all groups are broken")
}
}
func TestGetLeastLoadedGroup(t *testing.T) {
g0 := newTestGroup(false)
g1 := newTestGroup(false)
var n atomic.Uint32
bus := &backendURLs{groups: []*backendURLGroup{g0, g1}, n: &n}
// both groups are idle - the first one is picked
if g := bus.getLeastLoaded(); g != g0 {
t.Fatalf("expecting g0 to be returned when all groups are idle")
}
// g0 becomes more loaded than g1 - g1 must be picked
g0.bus[0].concurrentRequests.Add(5)
if g := bus.getLeastLoaded(); g != g1 {
t.Fatalf("expecting g1 to be returned when it is less loaded than g0")
}
// g1 is broken - g0 must be picked despite being more loaded
g1.bus[0].setBroken()
if g := bus.getLeastLoaded(); g != g0 {
t.Fatalf("expecting g0 to be returned when g1 is broken")
}
// all groups are broken - the first one is returned
g0.bus[0].setBroken()
if g := bus.getLeastLoaded(); g != g0 {
t.Fatalf("expecting g0 to be returned when all groups are broken")
}
}
func TestGetBackendURLTwoTier(t *testing.T) {
// Simulates two backends (busOriginal entries), each expanded into two discovered targets.
up := &URLPrefix{}
up.backendGroupCounters = make([]atomic.Uint32, 2)
bus := newBackendURLs(&up.n)
g0 := bus.addGroup([]*url.URL{{Host: "g0-a"}, {Host: "g0-b"}}, &up.backendGroupCounters[0])
g0.loadBalancingPolicy = "first_available"
g1 := bus.addGroup([]*url.URL{{Host: "g1-a"}, {Host: "g1-b"}}, &up.backendGroupCounters[1])
g1.loadBalancingPolicy = "first_available"
up.bus.Store(bus)
up.loadBalancingPolicy = "first_available"
if n := up.getBackendsCount(); n != 4 {
t.Fatalf("unexpected backends count; got %d; want 4", n)
}
// Both policies are first_available, so vmauth must always pick group0's first target.
for range 5 {
bu := up.getBackendURL()
if bu.url.Host != "g0-a" {
t.Fatalf("unexpected target; got %q; want g0-a", bu.url.Host)
}
bu.put()
}
// Mark group0's first target broken - the backend-level policy falls back to g0-b,
// while the top-level group selection still prefers group0, since it has a non-broken target.
bus.groups[0].bus[0].setBroken()
bu := up.getBackendURL()
if bu.url.Host != "g0-b" {
t.Fatalf("unexpected target; got %q; want g0-b", bu.url.Host)
}
bu.put()
// Mark group0 fully broken - the top-level selection falls back to group1.
bus.groups[0].bus[1].setBroken()
bu = up.getBackendURL()
if bu.url.Host != "g1-a" {
t.Fatalf("unexpected target; got %q; want g1-a", bu.url.Host)
}
bu.put()
}
func TestURLPrefixUnmarshalYAMLBackendGroup(t *testing.T) {
var up URLPrefix
data := []byte(`
- http://plain-backend
- url_prefix:
- http://group-a
- http://group-b
load_balancing_policy: least_loaded
discover_backend_ips: true
max_concurrent_requests: 7
tls_insecure_skip_verify: true
`)
if err := yaml.Unmarshal(data, &up); err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(up.busOriginal) != 2 {
t.Fatalf("unexpected number of busOriginal entries; got %d; want 2", len(up.busOriginal))
}
spec0 := up.busOriginal[0]
if len(spec0.urls) != 1 || spec0.urls[0].String() != "http://plain-backend" {
t.Fatalf("unexpected spec0 urls: %v", spec0.urls)
}
if spec0.loadBalancingPolicy != "" || spec0.maxConcurrentRequests != 0 || spec0.hasTLSOverride() {
t.Fatalf("unexpected overrides on a plain string spec: %+v", spec0)
}
spec1 := up.busOriginal[1]
if len(spec1.urls) != 2 || spec1.urls[0].String() != "http://group-a" || spec1.urls[1].String() != "http://group-b" {
t.Fatalf("unexpected spec1 urls: %v", spec1.urls)
}
if spec1.loadBalancingPolicy != "least_loaded" {
t.Fatalf("unexpected spec1.loadBalancingPolicy; got %q; want %q", spec1.loadBalancingPolicy, "least_loaded")
}
if spec1.discoverBackendIPs == nil || !*spec1.discoverBackendIPs {
t.Fatalf("expecting spec1.discoverBackendIPs=true")
}
if spec1.maxConcurrentRequests != 7 {
t.Fatalf("unexpected spec1.maxConcurrentRequests; got %d; want 7", spec1.maxConcurrentRequests)
}
if spec1.tlsInsecureSkipVerify == nil || !*spec1.tlsInsecureSkipVerify {
t.Fatalf("expecting spec1.tlsInsecureSkipVerify=true")
}
}
func TestURLPrefixUnmarshalYAMLBackendGroupInvalidPolicy(t *testing.T) {
var up URLPrefix
data := []byte(`
- url_prefix: http://a
load_balancing_policy: bogus
`)
if err := yaml.Unmarshal(data, &up); err == nil {
t.Fatalf("expecting non-nil error for invalid load_balancing_policy in a backend group")
}
}
func TestURLPrefixUnmarshalYAMLBackendGroupMissingURLPrefix(t *testing.T) {
var up URLPrefix
data := []byte(`
- discover_backend_ips: true
`)
if err := yaml.Unmarshal(data, &up); err == nil {
t.Fatalf("expecting non-nil error for missing url_prefix in a backend group mapping")
}
}
func TestSanitizeAndInitializeBackendGroupOverrides(t *testing.T) {
data := []byte(`
users:
- username: foo
password: bar
load_balancing_policy: first_available
url_prefix:
- http://primary-a
- url_prefix:
- http://standby-a
- http://standby-b
load_balancing_policy: least_loaded
discover_backend_ips: true
max_concurrent_requests: 5
tls_insecure_skip_verify: true
`)
ac, err := parseAuthConfig(data)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
m, err := parseAuthConfigUsers(ac)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
var ui *UserInfo
for _, u := range m {
ui = u
}
if ui == nil {
t.Fatalf("expecting a single parsed user")
}
up := ui.URLPrefix
bus := up.bus.Load()
if len(bus.groups) != 2 {
t.Fatalf("unexpected number of groups; got %d; want 2", len(bus.groups))
}
g0 := bus.groups[0]
if g0.loadBalancingPolicy != "first_available" {
t.Fatalf("unexpected g0 loadBalancingPolicy; got %q; want %q (inherited)", g0.loadBalancingPolicy, "first_available")
}
if g0.rt != nil {
t.Fatalf("expecting g0.rt to be nil (no per-group tls override)")
}
if g0.concurrencyLimitCh != nil {
t.Fatalf("expecting g0.concurrencyLimitCh to be nil (no per-group max_concurrent_requests)")
}
g1 := bus.groups[1]
if g1.loadBalancingPolicy != "least_loaded" {
t.Fatalf("unexpected g1 loadBalancingPolicy; got %q; want %q (overridden)", g1.loadBalancingPolicy, "least_loaded")
}
if g1.rt == nil {
t.Fatalf("expecting g1.rt to be non-nil due to tls_insecure_skip_verify override")
}
if cap(g1.concurrencyLimitCh) != 5 {
t.Fatalf("unexpected g1 concurrency limit capacity; got %d; want 5", cap(g1.concurrencyLimitCh))
}
if len(g1.bus) != 2 {
t.Fatalf("unexpected g1 backend count; got %d; want 2", len(g1.bus))
}
if !up.hasAnyBackendDiscovery {
t.Fatalf("expecting hasAnyBackendDiscovery=true, since g1 overrides discover_backend_ips=true")
}
// g1 has no explicit `name`, so its metrics must fall back to its ordinal position (index 1).
wantCounter := ac.ms.GetOrCreateCounter(`vmauth_backend_group_concurrent_requests_limit_reached_total{username="foo",backend_group="1"}`)
if wantCounter != g1.concurrencyLimitReached {
t.Fatalf("g1's concurrency limit metric wasn't registered with the expected ordinal backend_group label")
}
}
func TestSanitizeAndInitializeBackendGroupName(t *testing.T) {
data := []byte(`
users:
- username: foo
password: bar
url_prefix:
- url_prefix: http://primary-a
name: primary
max_concurrent_requests: 3
`)
ac, err := parseAuthConfig(data)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
m, err := parseAuthConfigUsers(ac)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
var ui *UserInfo
for _, u := range m {
ui = u
}
if ui == nil {
t.Fatalf("expecting a single parsed user")
}
g0 := ui.URLPrefix.bus.Load().groups[0]
wantCounter := ac.ms.GetOrCreateCounter(`vmauth_backend_group_concurrent_requests_limit_reached_total{username="foo",backend_group="primary"}`)
if wantCounter != g0.concurrencyLimitReached {
t.Fatalf("g0's concurrency limit metric wasn't registered with the explicit backend_group=\"primary\" label")
}
}
func TestBackendURLGroupConcurrencyLimit(t *testing.T) {
g := &backendURLGroup{
concurrencyLimitCh: make(chan struct{}, 1),
}
if !g.beginConcurrencyLimit() {
t.Fatalf("expecting first beginConcurrencyLimit to succeed")
}
if g.beginConcurrencyLimit() {
t.Fatalf("expecting second beginConcurrencyLimit to fail since the limit is 1")
}
if !g.isAtConcurrencyLimit() {
t.Fatalf("expecting isAtConcurrencyLimit to be true")
}
g.endConcurrencyLimit()
if g.isAtConcurrencyLimit() {
t.Fatalf("expecting isAtConcurrencyLimit to be false after endConcurrencyLimit")
}
if !g.beginConcurrencyLimit() {
t.Fatalf("expecting beginConcurrencyLimit to succeed again after endConcurrencyLimit")
}
}
func TestBackendURLGroupConcurrencyLimitDisabled(t *testing.T) {
g := &backendURLGroup{}
for range 10 {
if !g.beginConcurrencyLimit() {
t.Fatalf("expecting beginConcurrencyLimit to always succeed when no limit is configured")
}
}
// must not panic/block when no limit is configured
g.endConcurrencyLimit()
}
func TestGetFirstAvailableSkipsConcurrencyLimitedGroup(t *testing.T) {
g0 := newTestGroup(false)
g0.concurrencyLimitCh = make(chan struct{}, 1)
g0.concurrencyLimitCh <- struct{}{} // saturate g0
g1 := newTestGroup(false)
bus := &backendURLs{groups: []*backendURLGroup{g0, g1}}
if g := bus.getFirstAvailable(); g != g1 {
t.Fatalf("expecting g1 to be returned since g0 is at its concurrency limit")
}
}
func TestGetLeastLoadedSkipsConcurrencyLimitedGroup(t *testing.T) {
g0 := newTestGroup(false)
g0.concurrencyLimitCh = make(chan struct{}, 1)
g0.concurrencyLimitCh <- struct{}{} // saturate g0
g1 := newTestGroup(false)
var n atomic.Uint32
bus := &backendURLs{groups: []*backendURLGroup{g0, g1}, n: &n}
if g := bus.getLeastLoaded(); g != g1 {
t.Fatalf("expecting g1 to be returned since g0 is at its concurrency limit")
}
}
func getRegexs(paths []string) []*Regex {
var sps []*Regex
for _, path := range paths {
@@ -1092,14 +1559,12 @@ func mustParseURL(u string) *URLPrefix {
}
func mustParseURLs(us []string) *URLPrefix {
bus := newBackendURLs()
urls := make([]*url.URL, len(us))
for i, u := range us {
pu, err := url.Parse(u)
if err != nil {
panic(fmt.Errorf("BUG: cannot parse %q: %w", u, err))
}
bus.add(pu)
urls[i] = pu
}
up := &URLPrefix{}
@@ -1108,8 +1573,11 @@ func mustParseURLs(us []string) *URLPrefix {
} else {
up.vOriginal = us
}
up.busOriginal = []*backendGroupSpec{{urls: urls}}
up.backendGroupCounters = make([]atomic.Uint32, 1)
bus := newBackendURLs(&up.n)
bus.addGroup(urls, &up.backendGroupCounters[0])
up.bus.Store(bus)
up.busOriginal = urls
return up
}

View File

@@ -163,7 +163,7 @@ func parseJWTUsers(ac *AuthConfig, oidcDP *oidcDiscovererPool) ([]*UserInfo, err
return nil, err
}
if err := ui.initURLs(); err != nil {
if err := ui.initURLs(ac.ms); err != nil {
return nil, err
}
@@ -186,7 +186,7 @@ func parseJWTUsers(ac *AuthConfig, oidcDP *oidcDiscovererPool) ([]*UserInfo, err
return float64(len(ui.concurrencyLimitCh))
})
rt, err := newRoundTripper(ui.TLSCAFile, ui.TLSCertFile, ui.TLSKeyFile, ui.TLSServerName, ui.TLSInsecureSkipVerify)
rt, err := newRoundTripper(ui.BackendSettings)
if err != nil {
return nil, fmt.Errorf("cannot initialize HTTP RoundTripper: %w", err)
}
@@ -422,30 +422,32 @@ func parseJWTPlaceholdersForUserInfo(ui *UserInfo, isAllowed bool) error {
}
func validateJWTPlaceholdersForURL(up *URLPrefix, isAllowed bool) error {
for _, bu := range up.busOriginal {
ok := strings.Contains(bu.Path, placeholderPrefix)
if ok && !isAllowed {
return fmt.Errorf("placeholder: %q is only allowed at JWT token context", bu.Path)
}
if ok {
p := bu.Path
for _, ph := range allPlaceholders {
p = strings.ReplaceAll(p, ph, ``)
for _, spec := range up.busOriginal {
for _, bu := range spec.urls {
ok := strings.Contains(bu.Path, placeholderPrefix)
if ok && !isAllowed {
return fmt.Errorf("placeholder: %q is only allowed at JWT token context", bu.Path)
}
if strings.Contains(p, placeholderPrefix) {
return fmt.Errorf("invalid placeholder found in URL request path: %q, supported values are: %s", bu.Path, strings.Join(allPlaceholders, ", "))
}
}
for param, values := range bu.Query() {
for _, value := range values {
ok := strings.Contains(value, placeholderPrefix)
if ok && !isAllowed {
return fmt.Errorf("query param: %q with placeholder: %q is only allowed at JWT token context", param, value)
if ok {
p := bu.Path
for _, ph := range allPlaceholders {
p = strings.ReplaceAll(p, ph, ``)
}
if ok {
// possible placeholder
if !slices.Contains(allPlaceholders, value) {
return fmt.Errorf("query param: %q has unsupported placeholder string: %q, supported values are: %s", param, value, strings.Join(allPlaceholders, ", "))
if strings.Contains(p, placeholderPrefix) {
return fmt.Errorf("invalid placeholder found in URL request path: %q, supported values are: %s", bu.Path, strings.Join(allPlaceholders, ", "))
}
}
for param, values := range bu.Query() {
for _, value := range values {
ok := strings.Contains(value, placeholderPrefix)
if ok && !isAllowed {
return fmt.Errorf("query param: %q with placeholder: %q is only allowed at JWT token context", param, value)
}
if ok {
// possible placeholder
if !slices.Contains(allPlaceholders, value) {
return fmt.Errorf("query param: %q has unsupported placeholder string: %q, supported values are: %s", param, value, strings.Join(allPlaceholders, ", "))
}
}
}
}

View File

@@ -431,6 +431,11 @@ func processRequest(w http.ResponseWriter, r *http.Request, ui *UserInfo, tkn *j
if bu == nil {
break
}
if !bu.group.beginConcurrencyLimit() {
// The backend group hit its own max_concurrent_requests limit - skip it and try another backend.
bu.put()
continue
}
targetURL := bu.url
if tkn != nil {
vmac := tkn.VMAccess()
@@ -459,6 +464,7 @@ func processRequest(w http.ResponseWriter, r *http.Request, ui *UserInfo, tkn *j
goto again
}
bu.group.endConcurrencyLimit()
bu.put()
if ok {
return
@@ -493,7 +499,11 @@ func tryProcessingRequest(w http.ResponseWriter, r *http.Request, targetURL *url
bb, bbOK := req.Body.(*bufferedBody)
canRetry := !bbOK || bb.canRetry()
res, err := ui.rt.RoundTrip(req)
rt := bu.group.rt
if rt == nil {
rt = ui.rt
}
res, err := rt.RoundTrip(req)
if err == nil {
defer func() { _ = res.Body.Close() }()
}
@@ -711,25 +721,25 @@ var (
bufferRequestBodyDuration = metrics.NewSummary(`vmauth_buffer_request_body_duration_seconds`)
)
func newRoundTripper(caFileOpt, certFileOpt, keyFileOpt, serverNameOpt string, insecureSkipVerifyP *bool) (http.RoundTripper, error) {
func newRoundTripper(bs BackendSettings) (http.RoundTripper, error) {
caFile := *backendTLSCAFile
if caFileOpt != "" {
caFile = caFileOpt
if bs.TLSCAFile != "" {
caFile = bs.TLSCAFile
}
certFile := *backendTLSCertFile
if certFileOpt != "" {
certFile = certFileOpt
if bs.TLSCertFile != "" {
certFile = bs.TLSCertFile
}
keyFile := *backendTLSKeyFile
if keyFileOpt != "" {
keyFile = keyFileOpt
if bs.TLSKeyFile != "" {
keyFile = bs.TLSKeyFile
}
serverName := *backendTLSServerName
if serverNameOpt != "" {
serverName = serverNameOpt
if bs.TLSServerName != "" {
serverName = bs.TLSServerName
}
insecureSkipVerify := *backendTLSInsecureSkipVerify
if p := insecureSkipVerifyP; p != nil {
if p := bs.TLSInsecureSkipVerify; p != nil {
insecureSkipVerify = *p
}
opts := &promauth.Options{

View File

@@ -590,6 +590,65 @@ X-Forwarded-For: 12.34.56.78, 42.2.3.84`
f(cfgStr, requestURL, backendHandler, responseExpected)
}
func TestRequestHandlerSkipsBackendGroupAtConcurrencyLimit(t *testing.T) {
tsA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "backend=A")
}))
defer tsA.Close()
tsB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "backend=B")
}))
defer tsB.Close()
cfgStr := fmt.Sprintf(`
unauthorized_user:
load_balancing_policy: first_available
url_prefix:
- url_prefix: %s/foo
max_concurrent_requests: 1
- %s/foo
`, tsA.URL, tsB.URL)
cfgOrigP := authConfigData.Load()
if _, err := reloadAuthConfigData([]byte(cfgStr)); err != nil {
t.Fatalf("cannot load config data: %s", err)
}
defer func() {
cfgOrig := []byte("unauthorized_user:\n url_prefix: http://foo/bar")
if cfgOrigP != nil {
cfgOrig = *cfgOrigP
}
if _, err := reloadAuthConfigData(cfgOrig); err != nil {
t.Fatalf("cannot load the original config: %s", err)
}
}()
ac := authConfig.Load()
up := ac.UnauthorizedUser.URLPrefix
g0 := up.bus.Load().groups[0]
if cap(g0.concurrencyLimitCh) != 1 {
t.Fatalf("unexpected group0 concurrency limit; got %d; want 1", cap(g0.concurrencyLimitCh))
}
// Saturate group0's concurrency limit, simulating an in-flight request to backend A.
g0.concurrencyLimitCh <- struct{}{}
defer func() { <-g0.concurrencyLimitCh }()
r, err := http.NewRequest(http.MethodGet, "http://some-host.com/abc", nil)
if err != nil {
t.Fatalf("cannot initialize http request: %s", err)
}
r.RequestURI = r.URL.RequestURI()
w := &fakeResponseWriter{}
if !requestHandlerWithInternalRoutes(w, r) {
t.Fatalf("unexpected false is returned from requestHandler")
}
resp := w.getResponse()
if !strings.Contains(resp, "backend=B") {
t.Fatalf("expecting the request to be routed to backend B, since backend A's group is at its concurrency limit; got response:\n%s", resp)
}
}
func TestJWTRequestHandler(t *testing.T) {
// Generate RSA key pair for testing
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)

View File

@@ -87,7 +87,7 @@ func TestCreateTargetURLSuccess(t *testing.T) {
expectedRetryStatusCodes []int, expectedLoadBalancingPolicy string, expectedDropSrcPathPrefixParts int) {
t.Helper()
if err := ui.initURLs(); err != nil {
if err := ui.initURLs(nil); err != nil {
t.Fatalf("cannot initialize urls inside UserInfo: %s", err)
}
u, err := url.Parse(requestURI)
@@ -172,8 +172,10 @@ func TestCreateTargetURLSuccess(t *testing.T) {
mustNewHeader("'x: y'"),
},
},
RetryStatusCodes: []int{503, 501},
LoadBalancingPolicy: "first_available",
RetryStatusCodes: []int{503, 501},
BackendSettings: BackendSettings{
LoadBalancingPolicy: "first_available",
},
DropSrcPathPrefixParts: new(2),
}, "/a/b/c", "http://foo.bar/c", `bb: aaa`, `x: y`, []int{503, 501}, "first_available", 2)
f(&UserInfo{
@@ -402,10 +404,12 @@ func TestUserInfoGetBackendURL_SRV(t *testing.T) {
URLPrefix: mustParseURL("http://vminsert:8480"),
},
},
DiscoverBackendIPs: &allowed,
URLPrefix: mustParseURL("http://non-exist-dns-addr"),
BackendSettings: BackendSettings{
DiscoverBackendIPs: &allowed,
},
URLPrefix: mustParseURL("http://non-exist-dns-addr"),
}
if err := ui.initURLs(); err != nil {
if err := ui.initURLs(nil); err != nil {
t.Fatalf("cannot initialize urls inside UserInfo: %s", err)
}
@@ -455,10 +459,12 @@ func TestUserInfoGetBackendURL_SRVZeroBackends(t *testing.T) {
URLPrefix: mustParseURL("http://srv+vmselect"),
},
},
DiscoverBackendIPs: &allowed,
URLPrefix: mustParseURL("http://non-exist-dns-addr"),
BackendSettings: BackendSettings{
DiscoverBackendIPs: &allowed,
},
URLPrefix: mustParseURL("http://non-exist-dns-addr"),
}
if err := ui.initURLs(); err != nil {
if err := ui.initURLs(nil); err != nil {
t.Fatalf("cannot initialize urls inside UserInfo: %s", err)
}

View File

@@ -26,6 +26,8 @@ See also [LTS releases](https://docs.victoriametrics.com/victoriametrics/lts-rel
## tip
* FEATURE: [vmauth](https://docs.victoriametrics.com/victoriametrics/vmauth/): allow specifying individual `url_prefix` list items as a mapping instead of a plain url string, so a group of backend urls can override `load_balancing_policy`, `discover_backend_ips`, `max_concurrent_requests` and `tls_*` options, instead of inheriting them from the enclosing `user` / `url_map` scope. This makes it possible, for example, to prefer a primary backend group over a standby one via the outer `load_balancing_policy`, while independently load-balancing across the targets discovered for each group. Such a group can also be given an optional `name`, used to identify it in [metrics](https://docs.victoriametrics.com/victoriametrics/vmauth/#concurrency-limiting) (falls back to its ordinal position in `url_prefix` when not set). See [these docs](https://docs.victoriametrics.com/victoriametrics/vmauth/#load-balancing) for more details.
## [v1.148.0](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v1.148.0)
Released at 2026-07-20

View File

@@ -946,6 +946,38 @@ Each `url_prefix` in the [-auth.config](#auth-config) can be specified in the fo
load_balancing_policy: first_available
```
The `load_balancing_policy` option (and the `-loadBalancingPolicy` command-line flag) selects among the backends listed under `url_prefix`.
Each `url_prefix` list item can also be specified as a mapping instead of a plain url string, letting a group of backend urls override
`load_balancing_policy`, `discover_backend_ips`, `max_concurrent_requests` and `tls_*` options for that particular group, instead of inheriting
them from the enclosing `user` / `url_map` scope. This is useful when a single backend is expanded into multiple discovered targets via
[`discover_backend_ips`](#discovering-backend-ips): the outer `load_balancing_policy` still selects among the configured backends,
while the group's own `load_balancing_policy` selects among the targets discovered for that particular backend.
For example, the following config uses `first_available` to pick between the two availability zones, while spreading load evenly across the instances discovered in the active zone:
```yaml
unauthorized_user:
load_balancing_policy: first_available
url_prefix:
- name: az1
url_prefix: http://vmselect-az1:8481/
discover_backend_ips: true
load_balancing_policy: least_loaded
- name: az2
url_prefix: http://vmselect-az2:8481/
discover_backend_ips: true
load_balancing_policy: least_loaded
```
A backend group mapping supports the following options, all of which are optional and fall back to the value inherited from the
enclosing `user` / `url_map` scope when omitted:
* `url_prefix` - a single url or a list of urls for this group. This is the only required field.
* `name` - optionally identifies this group in its [metrics](#monitoring) (see `max_concurrent_requests` below). When not set, the group's ordinal position in `url_prefix` (`0`, `1`, ...) is used instead.
* `load_balancing_policy` - overrides the policy used for selecting among this group's own targets (its static urls, or the ones discovered via `discover_backend_ips`).
* `discover_backend_ips` - overrides whether backend IPs are discovered for this group. See [discovering backend IPs](#discovering-backend-ips).
* `max_concurrent_requests` - limits the number of concurrent requests proxied to this group specifically, on top of the `user`-level and global concurrency limits. Requests that hit this limit are sent to another backend instead of being queued or rejected - see [concurrency limiting](#concurrency-limiting).
* `tls_ca_file`, `tls_cert_file`, `tls_key_file`, `tls_server_name`, `tls_insecure_skip_verify` - override the TLS settings used when connecting to this group's backends. See [backend TLS setup](#backend-tls-setup).
The load balancing feature can be used in the following cases:
* Balancing the load among multiple `vmselect` and/or `vminsert` nodes in [VictoriaMetrics cluster](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/).
@@ -1021,7 +1053,11 @@ There are the following solutions for this issue:
This functionality is useful for balancing load across backend instances running on different TCP ports, since DNS SRV records include TCP ports.
The `discover_backend_ips` option can be specified at `user` and `url_map` level in the [`-auth.config`](#auth-config). It can also be enabled globally via the `-discoverBackendIPs` command-line flag.
The `discover_backend_ips` option can be specified at `user`, `url_map`, and individual backend group level in the [`-auth.config`](#auth-config) (see [load balancing docs](#load-balancing)
for the backend group mapping syntax). It can also be enabled globally via the `-discoverBackendIPs` command-line flag.
By default, `vmauth` uses the same load balancing policy for selecting among the discovered targets of a backend as the one used for selecting among the configured backends themselves.
This can be changed via the `load_balancing_policy` option inside a backend group mapping. See [these docs](#load-balancing) for more details.
See also [load balancing docs](#load-balancing).
@@ -1146,6 +1182,16 @@ The following [metrics](https://docs.victoriametrics.com/victoriametrics/vmauth/
* `vmauth_unauthorized_user_concurrent_requests_limit_reached_total` - the number of requests rejected with `429 Too Many Requests` error
because the concurrency limit has been reached for unauthorized users (if the `unauthorized_user` section is used).
It is also possible to limit the number of concurrent requests proxied to an individual backend group via the `max_concurrent_requests` option
inside a [backend group mapping](#load-balancing). Unlike the per-user and global limits above, hitting a backend group's own limit doesn't queue
the request or return `429 Too Many Requests` - `vmauth` immediately tries another backend instead, since the goal is to protect a single backend
from overload while still using the rest of the configured backends. The `backend_group` label below is the group's `name` if set, or its ordinal
position in `url_prefix` (`0`, `1`, ...) otherwise. The following metrics are exposed for backend groups which set `max_concurrent_requests`:
* `vmauth_backend_group_concurrent_requests_capacity{backend_group="..."}` - the configured `max_concurrent_requests` limit for the given backend group.
* `vmauth_backend_group_concurrent_requests_current{backend_group="..."}` - the current number of concurrent requests proxied to the given backend group.
* `vmauth_backend_group_concurrent_requests_limit_reached_total{backend_group="..."}` - the number of times the given backend group's limit was reached and the request was retried at another backend.
See also [request body buffering](https://docs.victoriametrics.com/victoriametrics/vmauth/#request-body-buffering).
## Request body buffering
@@ -1213,6 +1259,9 @@ By default, `vmauth` uses system settings when performing requests to HTTPS back
The `-backend.tlsCAFile`, `-backend.tlsCertFile`, `-backend.tlsKeyFile`, `tls_ca_file`, `tls_cert_file`, and `tls_key_file` may point either to a local file or to a `http` / `https` URL.
The file is checked for modifications every second and is automatically re-read when it is updated.
Any of the `tls_*` options above can also be set on an individual backend group inside `url_prefix` (see the [backend group mapping](#load-balancing) syntax),
overriding the per-user settings for that group only. This is useful when different backends behind a single user require different TLS credentials.
## IP filters
The [Enterprise version](https://docs.victoriametrics.com/victoriametrics/enterprise/) of `vmauth` can be configured to allow/deny incoming requests via global and per-user IP filters.