mirror of
https://github.com/traefik/traefik.git
synced 2026-06-19 07:36:07 +00:00
Resolve NGINX variables in ingress-nginx upstream-vhost annotation
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user