Resolve NGINX variables in ingress-nginx upstream-vhost annotation

This commit is contained in:
Michael
2026-04-16 12:14:10 +02:00
committed by GitHub
parent 7cacf027a1
commit eb22d72b48
10 changed files with 245 additions and 16 deletions
@@ -329,7 +329,7 @@ The following annotations are organized by category for easier navigation.
| <a id="opt-nginx-ingress-kubernetes-iobackend-protocol" href="#opt-nginx-ingress-kubernetes-iobackend-protocol" title="#opt-nginx-ingress-kubernetes-iobackend-protocol">`nginx.ingress.kubernetes.io/backend-protocol`</a> | FCGI and AUTO_HTTP not supported. |
| <a id="opt-nginx-ingress-kubernetes-ioservice-upstream" href="#opt-nginx-ingress-kubernetes-ioservice-upstream" title="#opt-nginx-ingress-kubernetes-ioservice-upstream">`nginx.ingress.kubernetes.io/service-upstream`</a> | |
| <a id="opt-nginx-ingress-kubernetes-ioupstream-hash-by" href="#opt-nginx-ingress-kubernetes-ioupstream-hash-by" title="#opt-nginx-ingress-kubernetes-ioupstream-hash-by">`nginx.ingress.kubernetes.io/upstream-hash-by`</a> | It supports minimal variable interpolation by using the following NGINX variables: `$scheme`, `$host`, `$http_*`, `$hostname`, `$request_uri`, `$request_method`, `$query_string`, `$args`, `$arg_*`, `$remote_addr`, `$uri`, `$document_uri`, `$server_name`, `$server_port`, `$content_type`, `$content_length`, `$cookie_*`, `$is_args`, `$best_http_host`, `$escaped_request_uri`, `$proxy_add_x_forwarded_for`. |
| <a id="opt-nginx-ingress-kubernetes-ioupstream-vhost" href="#opt-nginx-ingress-kubernetes-ioupstream-vhost" title="#opt-nginx-ingress-kubernetes-ioupstream-vhost">`nginx.ingress.kubernetes.io/upstream-vhost`</a> | |
| <a id="opt-nginx-ingress-kubernetes-ioupstream-vhost" href="#opt-nginx-ingress-kubernetes-ioupstream-vhost" title="#opt-nginx-ingress-kubernetes-ioupstream-vhost">`nginx.ingress.kubernetes.io/upstream-vhost`</a> | Supports NGINX variable interpolation. Request-time variables (`$scheme`, `$host`, `$http_*`, `$hostname`, `$request_uri`, `$request_method`, `$query_string`, `$args`, `$arg_*`, `$remote_addr`, `$uri`, `$document_uri`, `$server_name`, `$server_port`, `$content_type`, `$content_length`, `$cookie_*`, `$is_args`, `$best_http_host`, `$escaped_request_uri`, `$proxy_add_x_forwarded_for`) and the provider-resolved per-location variables (`$namespace`, `$ingress_name`, `$service_name`, `$service_port`, `$location_path`) are supported. The NGINX-internal variable `$proxy_upstream_name` is not available. |
| <a id="opt-nginx-ingress-kubernetes-iocustom-headers" href="#opt-nginx-ingress-kubernetes-iocustom-headers" title="#opt-nginx-ingress-kubernetes-iocustom-headers">`nginx.ingress.kubernetes.io/custom-headers`</a> | Header whitelisting, similar to `global-allowed-response-headers` NGINX config is not supported. |
| <a id="opt-nginx-ingress-kubernetes-iodefault-backend" href="#opt-nginx-ingress-kubernetes-iodefault-backend" title="#opt-nginx-ingress-kubernetes-iodefault-backend">`nginx.ingress.kubernetes.io/default-backend`</a> | Specifies a fallback service within the same namespace as the Ingress resource used to handle requests when the primary backend service has no active endpoints. If the specified service exposes multiple ports, the first port will receive the traffic. |
| <a id="opt-nginx-ingress-kubernetes-ioproxy-http-version" href="#opt-nginx-ingress-kubernetes-ioproxy-http-version" title="#opt-nginx-ingress-kubernetes-ioproxy-http-version">`nginx.ingress.kubernetes.io/proxy-http-version`</a> | Controls HTTP protocol version for backend communication. Supported value: `"1.1"` (disables HTTP/2 to backend). Value `"1.0"` is not supported and will log a warning. |
+15
View File
@@ -60,6 +60,7 @@ type Middleware struct {
AuthTLSPassCertificateToUpstream *AuthTLSPassCertificateToUpstream `json:"authTLSPassCertificateToUpstream,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"`
Snippet *Snippet `json:"snippet,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"`
RewriteTarget *RewriteTarget `json:"rewriteTarget,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"`
UpstreamVHost *UpstreamVHost `json:"upstreamVHost,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"`
}
// +k8s:deepcopy-gen=true
@@ -931,3 +932,17 @@ type RewriteTarget struct {
// XForwardedPrefix defines the value of the X-Forwarded-Prefix header.
XForwardedPrefix string `json:"xForwardedPrefix,omitempty"`
}
// +k8s:deepcopy-gen=true
// UpstreamVHost holds the upstream-vhost middleware configuration used by the ingress-nginx provider.
// It rewrites the request Host header from a template that may embed NGINX variables
// (e.g. $host, $service_name, $namespace) which are resolved at request time.
type UpstreamVHost struct {
// VHost is the Host header template. It may contain NGINX variables such as
// $host, $http_*, or provider-supplied variables like $service_name and $namespace.
VHost string `json:"vHost,omitempty"`
// Vars holds provider-resolved custom variables, keyed with their leading "$".
// For example: {"$service_name": "my-app", "$namespace": "foo"}.
Vars map[string]string `json:"vars,omitempty"`
}
@@ -1164,6 +1164,11 @@ func (in *Middleware) DeepCopyInto(out *Middleware) {
*out = new(RewriteTarget)
**out = **in
}
if in.UpstreamVHost != nil {
in, out := &in.UpstreamVHost, &out.UpstreamVHost
*out = new(UpstreamVHost)
(*in).DeepCopyInto(*out)
}
return
}
@@ -2771,6 +2776,29 @@ func (in *URLRewrite) DeepCopy() *URLRewrite {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *UpstreamVHost) DeepCopyInto(out *UpstreamVHost) {
*out = *in
if in.Vars != nil {
in, out := &in.Vars, &out.Vars
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamVHost.
func (in *UpstreamVHost) DeepCopy() *UpstreamVHost {
if in == nil {
return nil
}
out := new(UpstreamVHost)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in Users) DeepCopyInto(out *Users) {
{
@@ -0,0 +1,46 @@
package upstreamvhost
import (
"context"
"errors"
"net/http"
"github.com/traefik/traefik/v3/pkg/config/dynamic"
"github.com/traefik/traefik/v3/pkg/middlewares"
"github.com/traefik/traefik/v3/pkg/middlewares/ingressnginx"
)
const typeName = "UpstreamVHost"
type upstreamVHost struct {
name string
next http.Handler
vHost string
vars map[string]string
}
// New creates a new upstream-vhost middleware that rewrites req.Host from a
// template, resolving NGINX variables at request time.
func New(ctx context.Context, next http.Handler, config dynamic.UpstreamVHost, name string) (http.Handler, error) {
middlewares.GetLogger(ctx, name, typeName).Debug().Msg("Creating middleware")
if config.VHost == "" {
return nil, errors.New("vHost cannot be empty")
}
return &upstreamVHost{
name: name,
next: next,
vHost: config.VHost,
vars: config.Vars,
}, nil
}
func (u *upstreamVHost) GetTracingInformation() (string, string) {
return u.name, typeName
}
func (u *upstreamVHost) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
req.Host = ingressnginx.ReplaceVariables(u.vHost, req, nil, u.vars)
u.next.ServeHTTP(rw, req)
}
@@ -0,0 +1,100 @@
package upstreamvhost
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/traefik/traefik/v3/pkg/config/dynamic"
)
func TestUpstreamVhost(t *testing.T) {
testCases := []struct {
desc string
config dynamic.UpstreamVHost
reqHost string
expectedHost string
expectsError bool
}{
{
desc: "empty vhost",
config: dynamic.UpstreamVHost{
VHost: "",
},
expectsError: true,
},
{
desc: "static vhost",
config: dynamic.UpstreamVHost{
VHost: "backend.internal",
},
reqHost: "site.example.com",
expectedHost: "backend.internal",
},
{
desc: "provider-supplied $service_name and $namespace",
config: dynamic.UpstreamVHost{
VHost: "$service_name.$namespace",
Vars: map[string]string{
"$service_name": "my-app",
"$namespace": "foo",
},
},
reqHost: "site.example.com",
expectedHost: "my-app.foo",
},
{
desc: "request-time $host",
config: dynamic.UpstreamVHost{
VHost: "$host",
},
reqHost: "Site.Example.com:8080",
expectedHost: "site.example.com",
},
{
desc: "mix of static and request-time variables",
config: dynamic.UpstreamVHost{
VHost: "$service_name.$namespace.svc.cluster.local",
Vars: map[string]string{
"$service_name": "my-app",
"$namespace": "foo",
},
},
reqHost: "site.example.com",
expectedHost: "my-app.foo.svc.cluster.local",
},
{
desc: "unknown variable left as-is",
config: dynamic.UpstreamVHost{
VHost: "$does_not_exist",
},
reqHost: "site.example.com",
expectedHost: "$does_not_exist",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
var gotHost string
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
gotHost = r.Host
})
mw, err := New(t.Context(), next, test.config, "upstream-vhost")
if test.expectsError {
require.Error(t, err)
return
}
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "http://"+test.reqHost+"/", nil)
req.Host = test.reqHost
mw.ServeHTTP(httptest.NewRecorder(), req)
assert.Equal(t, test.expectedHost, gotHost)
})
}
}
@@ -91,7 +91,7 @@ type IngressConfig struct {
LimitBurstMultiplier *int `annotation:"nginx.ingress.kubernetes.io/limit-burst-multiplier"`
CustomHeaders *string `annotation:"nginx.ingress.kubernetes.io/custom-headers"`
UpstreamVhost *string `annotation:"nginx.ingress.kubernetes.io/upstream-vhost"`
UpstreamVHost *string `annotation:"nginx.ingress.kubernetes.io/upstream-vhost"`
XForwardedPrefix *string `annotation:"nginx.ingress.kubernetes.io/x-forwarded-prefix"`
CustomHTTPErrors *[]string `annotation:"nginx.ingress.kubernetes.io/custom-http-errors"`
@@ -64,7 +64,7 @@ func Test_parseIngressConfig(t *testing.T) {
ProxyMaxTempFileSize: ptr.To("100m"),
LimitRPM: ptr.To(120),
XForwardedPrefix: ptr.To("/test"),
UpstreamVhost: ptr.To("upstream-vhost"),
UpstreamVHost: ptr.To("upstream-vhost"),
},
},
{
@@ -78,8 +78,10 @@ var (
headerRegexp = regexp.MustCompile(`^[a-zA-Z\d\-_]+$`)
headerValueRegexp = regexp.MustCompile(`^[a-zA-Z\d_ :;.,\\/"'?!(){}\[\]@<>=\-+*#$&\x60|~^%]+$`)
// The same regexp used in ingress-nginx:https://github.com/kubernetes/ingress-nginx/blob/main/internal/ingress/inspector/rules.go.
// The same regexp used in ingress-nginx: https://github.com/kubernetes/ingress-nginx/blob/main/internal/ingress/inspector/rules.go.
strictPathTypeRegexp = regexp.MustCompile(`(?i)^/[[:alnum:]._\-/]*$`)
// The same regexp used in ingress-nginx: https://github.com/kubernetes/ingress-nginx/blob/main/internal/ingress/annotations/parser/validators.go#L77
regexPathWithCapture = regexp.MustCompile(`^/?[-._~a-zA-Z0-9/$:]*$`)
)
type unavailableError struct {
@@ -1417,7 +1419,7 @@ func (p *Provider) applyMiddlewares(ingress ingress, routerKey, rulePath, ruleHo
applyRewriteTargetConfiguration(rulePath, routerKey, ingress.IngressConfig, rt, conf)
applyUpstreamVhost(routerKey, ingress.IngressConfig, rt, conf)
applyUpstreamVHost(routerKey, rulePath, ingress, backend, rt, conf)
applyLimitRPMConfiguration(routerKey, ingress.IngressConfig, rt, conf)
@@ -1696,9 +1698,6 @@ func (p *Provider) applyCustomHeaders(namespace, routerName string, ingressConfi
return nil
}
// Validation identical to ingress-nginx.
var regexPathWithCapture = regexp.MustCompile(`^/?[-._~a-zA-Z0-9/$:]*$`)
func applyRewriteTargetConfiguration(rulePath, routerName string, ingressConfig IngressConfig, rt *dynamic.Router, conf *dynamic.Configuration) {
rewrite := ptr.Deref(ingressConfig.RewriteTarget, "")
if rewrite == "" {
@@ -1948,15 +1947,31 @@ func applyCORSConfiguration(routerName string, ingressConfig IngressConfig, rt *
rt.Middlewares = append(rt.Middlewares, corsMiddlewareName)
}
func applyUpstreamVhost(routerName string, ingressConfig IngressConfig, rt *dynamic.Router, conf *dynamic.Configuration) {
if ingressConfig.UpstreamVhost == nil {
func applyUpstreamVHost(routerName, rulePath string, ingress ingress, backend *netv1.IngressBackend, rt *dynamic.Router, conf *dynamic.Configuration) {
if ingress.IngressConfig.UpstreamVHost == nil {
return
}
// ingress-nginx exposes per-location variables (set at the NGINX location scope)
// to upstream-vhost: $namespace, $ingress_name, $service_name, $service_port,
// $location_path. They are static at config-build time, so we pass them through
// the interpolator's custom vars map. Request-time variables ($host, $http_*, ...)
// are resolved per request by the middleware itself via ingressnginx.ReplaceVariables.
vars := map[string]string{
"$namespace": ingress.Namespace,
"$ingress_name": ingress.Name,
"$location_path": rulePath,
}
if backend != nil && backend.Service != nil {
vars["$service_name"] = backend.Service.Name
vars["$service_port"] = portString(backend.Service.Port)
}
vHostMiddlewareName := routerName + "-vhost"
conf.HTTP.Middlewares[vHostMiddlewareName] = &dynamic.Middleware{
Headers: &dynamic.Headers{
CustomRequestHeaders: map[string]string{"Host": *ingressConfig.UpstreamVhost},
UpstreamVHost: &dynamic.UpstreamVHost{
VHost: *ingress.IngressConfig.UpstreamVHost,
Vars: vars,
},
}
@@ -2401,13 +2401,27 @@ func TestLoadIngresses(t *testing.T) {
},
Middlewares: map[string]*dynamic.Middleware{
"default-ingress-with-upstream-vhost-rule-0-path-0-vhost": {
Headers: &dynamic.Headers{
CustomRequestHeaders: map[string]string{"Host": "upstream-host-header-value"},
UpstreamVHost: &dynamic.UpstreamVHost{
VHost: "upstream-host-header-value",
Vars: map[string]string{
"$namespace": "default",
"$ingress_name": "ingress-with-upstream-vhost",
"$location_path": "/",
"$service_name": "whoami",
"$service_port": "80",
},
},
},
"default-ingress-with-upstream-vhost-rule-0-path-0-tls-vhost": {
Headers: &dynamic.Headers{
CustomRequestHeaders: map[string]string{"Host": "upstream-host-header-value"},
UpstreamVHost: &dynamic.UpstreamVHost{
VHost: "upstream-host-header-value",
Vars: map[string]string{
"$namespace": "default",
"$ingress_name": "ingress-with-upstream-vhost",
"$location_path": "/",
"$service_name": "whoami",
"$service_port": "80",
},
},
},
"default-ingress-with-upstream-vhost-rule-0-path-0-retry": {
+11
View File
@@ -28,6 +28,7 @@ import (
"github.com/traefik/traefik/v3/pkg/middlewares/ingressnginx/authtlspasscertificatetoupstream"
"github.com/traefik/traefik/v3/pkg/middlewares/ingressnginx/rewritetarget"
"github.com/traefik/traefik/v3/pkg/middlewares/ingressnginx/snippet"
"github.com/traefik/traefik/v3/pkg/middlewares/ingressnginx/upstreamvhost"
"github.com/traefik/traefik/v3/pkg/middlewares/ipallowlist"
"github.com/traefik/traefik/v3/pkg/middlewares/ipwhitelist"
"github.com/traefik/traefik/v3/pkg/middlewares/observability"
@@ -351,6 +352,16 @@ func (b *Builder) buildConstructor(ctx context.Context, middlewareName string) (
}
}
// UpstreamVHost
if config.UpstreamVHost != nil {
if middleware != nil {
return nil, badConf
}
middleware = func(next http.Handler) (http.Handler, error) {
return upstreamvhost.New(ctx, next, *config.UpstreamVHost, middlewareName)
}
}
// Retry
if config.Retry != nil {
if middleware != nil {