From d680fef7f18b7dd6fdc826520a749cdc39dd00ef Mon Sep 17 00:00:00 2001 From: Julien Salleyron Date: Wed, 4 Mar 2026 10:24:05 +0100 Subject: [PATCH] Implement server-snippet and configuration-snippet annotations Co-authored-by: Kevin Pollet --- .../configuration-options.md | 1 + .../kubernetes/ingress-nginx.md | 44 +- go.mod | 1 + go.sum | 8 + pkg/config/dynamic/middlewares.go | 9 + pkg/config/dynamic/zz_generated.deepcopy.go | 21 + pkg/middlewares/auth/forward.go | 6 +- pkg/middlewares/ingressnginx/interpolation.go | 118 +- .../ingressnginx/interpolation_test.go | 228 ++- .../ingressnginx/snippet/action.go | 1234 ++++++++++++++ .../ingressnginx/snippet/directive_context.go | 53 + .../ingressnginx/snippet/snippet.go | 232 +++ .../ingressnginx/snippet/snippet_test.go | 1499 +++++++++++++++++ .../kubernetes/ingress-nginx/annotations.go | 3 + .../ingresses/ingress-with-both-snippets.yml | 25 + .../ingress-with-configuration-snippet.yml | 23 + .../ingresses/ingress-with-server-snippet.yml | 23 + .../kubernetes/ingress-nginx/kubernetes.go | 97 +- .../ingress-nginx/kubernetes_test.go | 758 ++++++++- pkg/server/middleware/middlewares.go | 11 + 20 files changed, 4307 insertions(+), 87 deletions(-) create mode 100644 pkg/middlewares/ingressnginx/snippet/action.go create mode 100644 pkg/middlewares/ingressnginx/snippet/directive_context.go create mode 100644 pkg/middlewares/ingressnginx/snippet/snippet.go create mode 100644 pkg/middlewares/ingressnginx/snippet/snippet_test.go create mode 100644 pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-both-snippets.yml create mode 100644 pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-configuration-snippet.yml create mode 100644 pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-server-snippet.yml diff --git a/docs/content/reference/install-configuration/configuration-options.md b/docs/content/reference/install-configuration/configuration-options.md index 70f20735fa..cb82ccb460 100644 --- a/docs/content/reference/install-configuration/configuration-options.md +++ b/docs/content/reference/install-configuration/configuration-options.md @@ -393,6 +393,7 @@ THIS FILE MUST NOT BE EDITED BY HAND | providers.kubernetesingress.token | Kubernetes bearer token (not needed for in-cluster client). It accepts either a token value or a file path to the token. | | | providers.kubernetesingressnginx | Enables Kubernetes Ingress NGINX provider. | false | | providers.kubernetesingressnginx.allowcrossnamespaceresources | Allow Ingress to reference resources (e.g. ConfigMaps, Secrets) in different namespaces. | false | +| providers.kubernetesingressnginx.allowsnippetannotations | Enables to parse and add -snippet annotations/directives. | false | | providers.kubernetesingressnginx.certauthfilepath | Kubernetes certificate authority file path (not needed for in-cluster client). | | | providers.kubernetesingressnginx.clientbodybuffersize | Default buffer size for reading client request body. | 16384 | | providers.kubernetesingressnginx.controllerclass | Ingress Class Controller value this controller satisfies. | k8s.io/ingress-nginx | diff --git a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md index 0ffd561c26..26d3acc98e 100644 --- a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md +++ b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md @@ -278,16 +278,16 @@ The following annotations are organized by category for easier navigation. ### Authentication -| Annotation | Limitations / Notes | -|-------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `nginx.ingress.kubernetes.io/auth-type` | | -| `nginx.ingress.kubernetes.io/auth-secret` | | -| `nginx.ingress.kubernetes.io/auth-secret-type` | | -| `nginx.ingress.kubernetes.io/auth-realm` | | -| `nginx.ingress.kubernetes.io/auth-url` | Only URL and response headers copy supported. Forward auth behaves differently than NGINX. It supports minimal variable interpolation by using the following NGINX variables: `$scheme`, `$host`, `$http_*`, `$best_http_host`, `$hostname`, `$request_uri`, `$escaped_request_uri`, `$query_string`, `$args`, `$arg_*`, `$remote_addr`. | -| `nginx.ingress.kubernetes.io/auth-signin` | Redirects to signin URL on 401 response. It supports minimal variable interpolation by using the following NGINX variables: `$scheme`, `$host`, `$http_*`, `$best_http_host`, `$hostname`, `$request_uri`, `$escaped_request_uri`, `$query_string`, `$args`, `$arg_*`, `$remote_addr`. | -| `nginx.ingress.kubernetes.io/auth-method` | | -| `nginx.ingress.kubernetes.io/auth-response-headers` | | +| Annotation | Limitations / Notes | +|-------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `nginx.ingress.kubernetes.io/auth-type` | | +| `nginx.ingress.kubernetes.io/auth-secret` | | +| `nginx.ingress.kubernetes.io/auth-secret-type` | | +| `nginx.ingress.kubernetes.io/auth-realm` | | +| `nginx.ingress.kubernetes.io/auth-url` | Only URL and response headers copy supported. Forward auth behaves differently than NGINX. 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`. | +| `nginx.ingress.kubernetes.io/auth-signin` | Redirects to signin URL on 401 response. 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`. | +| `nginx.ingress.kubernetes.io/auth-method` | | +| `nginx.ingress.kubernetes.io/auth-response-headers` | | ### SSL/TLS @@ -345,18 +345,20 @@ The following annotations are organized by category for easier navigation. ### Routing -| Annotation | Limitations / Notes | -|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `nginx.ingress.kubernetes.io/app-root` | | -| `nginx.ingress.kubernetes.io/from-to-www-redirect` | Doesn't support wildcard hosts. | -| `nginx.ingress.kubernetes.io/use-regex` | | -| `nginx.ingress.kubernetes.io/rewrite-target` | | -| `nginx.ingress.kubernetes.io/permanent-redirect` | Defaults to a 301 Moved Permanently status code. | -| `nginx.ingress.kubernetes.io/permanent-redirect-code` | Only valid 3XX HTTP Status Codes are accepted. | -| `nginx.ingress.kubernetes.io/temporal-redirect` | Takes precedence over the `permanent-redirect` annotation. Defaults to a 302 Found status code. | -| `nginx.ingress.kubernetes.io/temporal-redirect-code` | Only valid 3XX HTTP Status Codes are accepted. | +| Annotation | Limitations / Notes | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `nginx.ingress.kubernetes.io/app-root` | | +| `nginx.ingress.kubernetes.io/from-to-www-redirect` | Doesn't support wildcard hosts. | +| `nginx.ingress.kubernetes.io/use-regex` | | +| `nginx.ingress.kubernetes.io/rewrite-target` | | +| `nginx.ingress.kubernetes.io/permanent-redirect` | Defaults to a 301 Moved Permanently status code. | +| `nginx.ingress.kubernetes.io/permanent-redirect-code` | Only valid 3XX HTTP Status Codes are accepted. | +| `nginx.ingress.kubernetes.io/temporal-redirect` | Takes precedence over the `permanent-redirect` annotation. Defaults to a 302 Found status code. | +| `nginx.ingress.kubernetes.io/temporal-redirect-code` | Only valid 3XX HTTP Status Codes are accepted. | | `nginx.ingress.kubernetes.io/custom-http-errors` | Specifies a comma-separated list of HTTP status codes that should be intercepted and served by an error page backend. When any of these status codes occur, the request is forwarded to the global default backend, or to the backend defined by the [default-backend](#opt-nginx-ingress-kubernetes-iodefault-backend) annotation if specified. | | `nginx.ingress.kubernetes.io/server-alias` | Ignored if the alias conflicts with an existing Ingress Host rule. Ingress Host rules always take precedence. | +| `nginx.ingress.kubernetes.io/server-snippet` | Supported directives: `add_header`, `more_set_headers`, `proxy_set_header`, `more_set_input_headers`, `set`, `if`, `return code [text]`. 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`. | +| `nginx.ingress.kubernetes.io/configuration-snippet` | Supported directives: `add_header`, `more_set_headers`, `proxy_set_header`, `more_set_input_headers`, `set`, `if`, `return code [text]`. 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`. | ### IP Whitelist @@ -438,7 +440,6 @@ The following annotations are organized by category for easier navigation. | `nginx.ingress.kubernetes.io/canary-by-cookie` | | | `nginx.ingress.kubernetes.io/canary-weight` | | | `nginx.ingress.kubernetes.io/canary-weight-total` | | -| `nginx.ingress.kubernetes.io/configuration-snippet` | | | `nginx.ingress.kubernetes.io/disable-proxy-intercept-errors` | | | `nginx.ingress.kubernetes.io/limit-rate-after` | | | `nginx.ingress.kubernetes.io/limit-rate` | | @@ -461,7 +462,6 @@ The following annotations are organized by category for easier navigation. | `nginx.ingress.kubernetes.io/proxy-ssl-protocols` | | | `nginx.ingress.kubernetes.io/enable-rewrite-log` | | | `nginx.ingress.kubernetes.io/satisfy` | | -| `nginx.ingress.kubernetes.io/server-snippet` | | | `nginx.ingress.kubernetes.io/session-cookie-conditional-samesite-none` | | | `nginx.ingress.kubernetes.io/session-cookie-change-on-failure` | | | `nginx.ingress.kubernetes.io/ssl-ciphers` | | diff --git a/go.mod b/go.mod index c47be1dac9..248d9c9b8e 100644 --- a/go.mod +++ b/go.mod @@ -74,6 +74,7 @@ require ( github.com/traefik/paerser v0.2.2 github.com/traefik/traefik/dynamic/ext v0.0.0-00010101000000-000000000000 github.com/traefik/yaegi v0.16.1 + github.com/tufanbarisyildirim/gonginx v0.0.0-20250620092546-c3e307e36701 // latest tag is too old. github.com/unrolled/render v1.0.2 github.com/unrolled/secure v1.0.9 github.com/valyala/fasthttp v1.58.0 diff --git a/go.sum b/go.sum index f5577b12af..b312887ae7 100644 --- a/go.sum +++ b/go.sum @@ -742,6 +742,8 @@ github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df/go.mod h1:QMZY7/J/KSQEhK github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/imega/luaformatter v0.0.0-20211025140405-86b0a68d6bef h1:RC993DdTIHNItsyLj79fgZNLzrf9tBN0GR6W5ZPms6s= +github.com/imega/luaformatter v0.0.0-20211025140405-86b0a68d6bef/go.mod h1:i2XCfvmO94HrEOQWllihhtPrkvNfuB2R2p/o6+OVnRU= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/influxdata/influxdb-client-go/v2 v2.7.0 h1:QgP5mlBE9sGnzplpnf96pr+p7uqlIlL4W2GAP3n+XZg= github.com/influxdata/influxdb-client-go/v2 v2.7.0/go.mod h1:Y/0W1+TZir7ypoQZYd2IrnVOKB3Tq6oegAQeSVN/+EU= @@ -1274,6 +1276,10 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/timtadh/data-structures v0.5.3 h1:F2tEjoG9qWIyUjbvXVgJqEOGJPMIiYn7U5W5mE+i/vQ= +github.com/timtadh/data-structures v0.5.3/go.mod h1:9R4XODhJ8JdWFEI8P/HJKqxuJctfBQw6fDibMQny2oU= +github.com/timtadh/lexmachine v0.2.2 h1:g55RnjdYazm5wnKv59pwFcBJHOyvTPfDEoz21s4PHmY= +github.com/timtadh/lexmachine v0.2.2/go.mod h1:GBJvD5OAfRn/gnp92zb9KTgHLB7akKyxmVivoYCcjQI= github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= @@ -1294,6 +1300,8 @@ github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E= github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY= github.com/transip/gotransip/v6 v6.26.1 h1:MeqIjkTBBsZwWAK6giZyMkqLmKMclVHEuTNmoBdx4MA= github.com/transip/gotransip/v6 v6.26.1/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s= +github.com/tufanbarisyildirim/gonginx v0.0.0-20250620092546-c3e307e36701 h1:JgeHIJzRSEdcuLXufZrni5+a4yDnBhQG+DdKhqCFhq0= +github.com/tufanbarisyildirim/gonginx v0.0.0-20250620092546-c3e307e36701/go.mod h1:ALbEe81QPWOZjDKCKNWodG2iqCMtregG8+ebQgjx2+4= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= diff --git a/pkg/config/dynamic/middlewares.go b/pkg/config/dynamic/middlewares.go index 434c654c63..2218d426f2 100644 --- a/pkg/config/dynamic/middlewares.go +++ b/pkg/config/dynamic/middlewares.go @@ -58,6 +58,7 @@ type Middleware struct { // ingress-nginx middlewares. AuthTLSPassCertificateToUpstream *AuthTLSPassCertificateToUpstream `json:"authTLSPassCertificateToUpstream,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` + Snippet *Snippet `json:"snippet,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` } // +k8s:deepcopy-gen=true @@ -895,3 +896,11 @@ type URLRewrite struct { Path *string `json:"path,omitempty"` PathPrefix *string `json:"pathPrefix,omitempty"` } + +// +k8s:deepcopy-gen=true + +// Snippet holds the NGINX snippet configuration. +type Snippet struct { + ServerSnippet string `json:"serverSnippet,omitempty"` + ConfigurationSnippet string `json:"configurationSnippet,omitempty"` +} diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index 5f342da8d6..8e859e17d9 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -1112,6 +1112,11 @@ func (in *Middleware) DeepCopyInto(out *Middleware) { *out = new(AuthTLSPassCertificateToUpstream) (*in).DeepCopyInto(*out) } + if in.Snippet != nil { + in, out := &in.Snippet, &out.Snippet + *out = new(Snippet) + **out = **in + } return } @@ -1810,6 +1815,22 @@ func (in *Service) DeepCopy() *Service { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Snippet) DeepCopyInto(out *Snippet) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Snippet. +func (in *Snippet) DeepCopy() *Snippet { + if in == nil { + return nil + } + out := new(Snippet) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SourceCriterion) DeepCopyInto(out *SourceCriterion) { *out = *in diff --git a/pkg/middlewares/auth/forward.go b/pkg/middlewares/auth/forward.go index 6b75f555ff..acdabbb2e2 100644 --- a/pkg/middlewares/auth/forward.go +++ b/pkg/middlewares/auth/forward.go @@ -157,7 +157,7 @@ func (fa *forwardAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) { address := fa.address if fa.interpolate { - address = ingressnginx.ReplaceVariables(address, req) + address = ingressnginx.ReplaceVariables(address, req, nil) } forwardReqMethod := http.MethodGet @@ -268,7 +268,7 @@ func (fa *forwardAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) { // If the signin URL doesn't contain "rd=" parameter, // add it with the original request URL to match the NGINX behavior. if !strings.Contains(signinURL, "rd=") { - suffix := "rd=$scheme://$host$escaped_request_uri" + suffix := "rd=$scheme://$best_http_host$escaped_request_uri" if !strings.Contains(signinURL, "?") { signinURL += "?" + suffix } else { @@ -276,7 +276,7 @@ func (fa *forwardAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } } - signinURL = ingressnginx.ReplaceVariables(signinURL, req) + signinURL = ingressnginx.ReplaceVariables(signinURL, req, nil) } tracer.CaptureResponse(forwardSpan, forwardResponse.Header, http.StatusFound, trace.SpanKindClient) diff --git a/pkg/middlewares/ingressnginx/interpolation.go b/pkg/middlewares/ingressnginx/interpolation.go index baf02a2a9a..61a61ba155 100644 --- a/pkg/middlewares/ingressnginx/interpolation.go +++ b/pkg/middlewares/ingressnginx/interpolation.go @@ -2,8 +2,10 @@ package ingressnginx import ( "fmt" + "net" "net/http" "net/url" + "os" "regexp" "strings" @@ -18,23 +20,39 @@ const ( httpHeaders = "$http_" hostname = "$hostname" requestURI = "$request_uri" + requestMethod = "$request_method" queryString = "$query_string" args = "$args" arg = "$arg_" remoteAddress = "$remote_addr" + uri = "$uri" + documentURI = "$document_uri" + serverName = "$server_name" + serverPort = "$server_port" + contentType = "$content_type" + contentLength = "$content_length" + cookie = "$cookie_" + isArgs = "$is_args" // Variables set by ingress-nginx template. - bestHTTPHost = "$best_http_host" - escapedRequestURI = "$escaped_request_uri" + bestHTTPHost = "$best_http_host" + escapedRequestURI = "$escaped_request_uri" + proxyAddXForwardedFor = "$proxy_add_x_forwarded_for" ) -// varRegexp is a regular expression to match NGINX variables in the form of $variable or $variable_name. -var varRegexp = regexp.MustCompile(`\$[a-zA-Z_][a-zA-Z0-9_]*`) +// varRegexp is a regular expression to match NGINX variables in the form of $variable, $variable_name, +// or capture group references $1-$9. +var varRegexp = regexp.MustCompile(`\$[a-zA-Z_][a-zA-Z0-9_]*|\$[1-9]`) // ReplaceVariables replaces NGINX variables in the given string with their corresponding values from the HTTP request. -func ReplaceVariables(str string, req *http.Request) string { +// Today this supports the `$scheme`, `$host`, `$http_*`, `$best_http_host`, `$hostname`, `$request_uri`, +// `$escaped_request_uri`, `$query_string`, `$args`, `$arg_*`, `$remote_addr`, `$request_method`, +// `$uri`, `$document_uri`, `$server_name`, `$server_port`, `$content_type`, `$content_length`, +// `$cookie_*`, `$is_args`, and `$proxy_add_x_forwarded_for` variables. +// Custom variables can be passed through the vars param. +func ReplaceVariables(str string, req *http.Request, vars map[string]string) string { return varRegexp.ReplaceAllStringFunc(str, func(variable string) string { - val, err := variableValue(variable, req) + val, err := variableValue(variable, req, vars) if err != nil { log.Ctx(req.Context()).Debug().Err(err).Msgf("Error replacing variable: %s", variable) return variable @@ -43,8 +61,8 @@ func ReplaceVariables(str string, req *http.Request) string { }) } -// variableValue returns the value of the given NGINX variable based on the HTTP request. -func variableValue(variable string, req *http.Request) (string, error) { +// variableValue returns the value of the given NGINX variable based on the HTTP request and the custom vars map. +func variableValue(variable string, req *http.Request, vars map[string]string) (string, error) { // $http_name variables are used to access HTTP headers in the request. if header, ok := strings.CutPrefix(variable, httpHeaders); ok { return strings.Join(req.Header.Values(strings.ReplaceAll(header, "_", "-")), ","), nil @@ -55,10 +73,34 @@ func variableValue(variable string, req *http.Request) (string, error) { return req.URL.Query().Get(arg), nil } + // $cookie_name variables are used to access cookie values in the request. + if name, ok := strings.CutPrefix(variable, cookie); ok { + c, _ := req.Cookie(name) + if c == nil { + return "", nil + } + return c.Value, nil + } + switch variable { - case host, hostname, bestHTTPHost: + case host: + // NGINX's $host returns the hostname without port, lowercased. + if hostOnly, _, err := net.SplitHostPort(req.Host); err == nil { + return strings.ToLower(hostOnly), nil + } + return strings.ToLower(req.Host), nil + + case bestHTTPHost: + // ingress-nginx's $best_http_host preserves the port. return req.Host, nil + case hostname: + h, err := os.Hostname() + if err != nil { + return "", fmt.Errorf("getting hostname: %w", err) + } + return h, nil + case requestURI: return req.URL.RequestURI(), nil @@ -72,12 +114,66 @@ func variableValue(variable string, req *http.Request) (string, error) { return "http", nil case args, queryString: - return req.URL.Query().Encode(), nil + return req.URL.RawQuery, nil case remoteAddress: - return req.RemoteAddr, nil + return stripPort(req.RemoteAddr), nil + + case proxyAddXForwardedFor: + clientIP := stripPort(req.RemoteAddr) + if prior := req.Header.Get("X-Forwarded-For"); prior != "" { + return prior + ", " + clientIP, nil + } + return clientIP, nil + + case requestMethod: + return req.Method, nil + + case uri, documentURI: + return req.URL.Path, nil + + case serverName: + if hostOnly, _, err := net.SplitHostPort(req.Host); err == nil { + return hostOnly, nil + } + return req.Host, nil + + case serverPort: + if _, port, err := net.SplitHostPort(req.Host); err == nil { + return port, nil + } + if req.TLS != nil { + return "443", nil + } + return "80", nil + + case contentType: + return req.Header.Get("Content-Type"), nil + + case contentLength: + return req.Header.Get("Content-Length"), nil + + case isArgs: + if req.URL.RawQuery != "" { + return "?", nil + } + return "", nil default: + if value, ok := vars[variable]; ok { + return value, nil + } + return "", fmt.Errorf("unsupported variable: %s", variable) } } + +// stripPort removes the port from a host:port address. +// It handles both IPv4 (192.168.1.1:8080) and IPv6 ([::1]:8080) formats. +func stripPort(addr string) string { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return addr + } + return host +} diff --git a/pkg/middlewares/ingressnginx/interpolation_test.go b/pkg/middlewares/ingressnginx/interpolation_test.go index b2a216353a..ddb4adfecd 100644 --- a/pkg/middlewares/ingressnginx/interpolation_test.go +++ b/pkg/middlewares/ingressnginx/interpolation_test.go @@ -1,8 +1,10 @@ package ingressnginx import ( + "crypto/tls" "net/http" "net/http/httptest" + "os" "testing" "github.com/stretchr/testify/require" @@ -13,6 +15,7 @@ func Test_ReplaceVariables(t *testing.T) { desc string src string req *http.Request + vars map[string]string expected string }{ { @@ -28,10 +31,11 @@ func Test_ReplaceVariables(t *testing.T) { expected: `val=baz.com`, }, { - desc: "$hostname", - src: "val=$hostname", - req: httptest.NewRequest(http.MethodGet, "http://baz.com/foo/bar?key=value&other=test", http.NoBody), - expected: `val=baz.com`, + desc: "$hostname", + src: "val=$hostname", + req: httptest.NewRequest(http.MethodGet, "http://baz.com/foo/bar?key=value&other=test", http.NoBody), + // $hostname returns the machine hostname (os.Hostname), not the HTTP Host header. + expected: "val=" + mustHostname(t), }, { desc: "$http_*", @@ -59,13 +63,13 @@ func Test_ReplaceVariables(t *testing.T) { desc: "$args", src: "val=$args", req: httptest.NewRequest(http.MethodGet, "http://baz.com?q=test&test=1&test=2&token=token_1234&val=foo,bar,baz", http.NoBody), - expected: `val=q=test&test=1&test=2&token=token_1234&val=foo%2Cbar%2Cbaz`, + expected: `val=q=test&test=1&test=2&token=token_1234&val=foo,bar,baz`, }, { desc: "$query_string", src: "val=$query_string", req: httptest.NewRequest(http.MethodGet, "http://baz.com?q=test&test=1&test=2&token=token_1234&val=foo,bar,baz", http.NoBody), - expected: `val=q=test&test=1&test=2&token=token_1234&val=foo%2Cbar%2Cbaz`, + expected: `val=q=test&test=1&test=2&token=token_1234&val=foo,bar,baz`, }, { desc: "$host && $escaped_request_uri", @@ -85,13 +89,178 @@ func Test_ReplaceVariables(t *testing.T) { req: httptest.NewRequest(http.MethodGet, "http://baz.com/foo/bar?key=value&other=test", http.NoBody), expected: "val=${invalid}", }, + { + desc: "$scheme http", + src: "val=$scheme", + req: httptest.NewRequest(http.MethodGet, "http://baz.com/foo/bar", http.NoBody), + expected: "val=http", + }, + { + desc: "$scheme https", + src: "val=$scheme", + req: mustNewRequestWithTLS(t, http.MethodGet, "https://baz.com/foo/bar"), + expected: "val=https", + }, + { + desc: "$request_uri", + src: "val=$request_uri", + req: httptest.NewRequest(http.MethodGet, "http://baz.com/foo/bar?key=value&other=test", http.NoBody), + expected: "val=/foo/bar?key=value&other=test", + }, + { + desc: "$remote_addr", + src: "val=$remote_addr", + req: mustNewRequestWithRemoteAddr(t, http.MethodGet, "http://baz.com/foo/bar", "192.168.1.1:12345"), + expected: "val=192.168.1.1", + }, + { + desc: "$remote_addr without port", + src: "val=$remote_addr", + req: mustNewRequestWithRemoteAddr(t, http.MethodGet, "http://baz.com/foo/bar", "192.168.1.1"), + expected: "val=192.168.1.1", + }, + { + desc: "custom vars", + src: "val=$custom_var", + req: httptest.NewRequest(http.MethodGet, "http://baz.com/foo/bar", http.NoBody), + vars: map[string]string{"$custom_var": "custom_value"}, + expected: "val=custom_value", + }, + { + desc: "$uri", + src: "val=$uri", + req: httptest.NewRequest(http.MethodGet, "http://baz.com/foo/bar?key=value", http.NoBody), + expected: "val=/foo/bar", + }, + { + desc: "$document_uri", + src: "val=$document_uri", + req: httptest.NewRequest(http.MethodGet, "http://baz.com/foo/bar?key=value", http.NoBody), + expected: "val=/foo/bar", + }, + { + desc: "$server_name with port", + src: "val=$server_name", + req: mustNewRequestWithHost(t, http.MethodGet, "http://baz.com:8080/foo", "baz.com:8080"), + expected: "val=baz.com", + }, + { + desc: "$server_name without port", + src: "val=$server_name", + req: httptest.NewRequest(http.MethodGet, "http://baz.com/foo", http.NoBody), + expected: "val=baz.com", + }, + { + desc: "$server_port with explicit port", + src: "val=$server_port", + req: mustNewRequestWithHost(t, http.MethodGet, "http://baz.com:8080/foo", "baz.com:8080"), + expected: "val=8080", + }, + { + desc: "$server_port without port http", + src: "val=$server_port", + req: httptest.NewRequest(http.MethodGet, "http://baz.com/foo", http.NoBody), + expected: "val=80", + }, + { + desc: "$server_port without port https", + src: "val=$server_port", + req: mustNewRequestWithTLS(t, http.MethodGet, "https://baz.com/foo"), + expected: "val=443", + }, + { + desc: "$content_type", + src: "val=$content_type", + req: mustNewRequestWithHeaders(t, http.MethodGet, "http://baz.com/foo", map[string][]string{ + "Content-Type": {"application/json"}, + }), + expected: "val=application/json", + }, + { + desc: "$content_length", + src: "val=$content_length", + req: mustNewRequestWithHeaders(t, http.MethodGet, "http://baz.com/foo", map[string][]string{ + "Content-Length": {"42"}, + }), + expected: "val=42", + }, + { + desc: "$cookie_session", + src: "val=$cookie_session", + req: mustNewRequestWithCookie(t, http.MethodGet, "http://baz.com/foo", "session", "abc123"), + expected: "val=abc123", + }, + { + desc: "$cookie_* not found", + src: "val=$cookie_missing", + req: httptest.NewRequest(http.MethodGet, "http://baz.com/foo", http.NoBody), + expected: "val=", + }, + { + desc: "$is_args with query string", + src: "val=$is_args", + req: httptest.NewRequest(http.MethodGet, "http://baz.com/foo?key=value", http.NoBody), + expected: "val=?", + }, + { + desc: "$is_args without query string", + src: "val=$is_args", + req: httptest.NewRequest(http.MethodGet, "http://baz.com/foo", http.NoBody), + expected: "val=", + }, + { + desc: "$host strips port", + src: "val=$host", + req: mustNewRequestWithHost(t, http.MethodGet, "http://baz.com:8080/foo", "baz.com:8080"), + expected: "val=baz.com", + }, + { + desc: "$host lowercased", + src: "val=$host", + req: mustNewRequestWithHost(t, http.MethodGet, "http://BAZ.COM/foo", "BAZ.COM"), + expected: "val=baz.com", + }, + { + desc: "$best_http_host preserves port", + src: "val=$best_http_host", + req: mustNewRequestWithHost(t, http.MethodGet, "http://baz.com:8080/foo", "baz.com:8080"), + expected: "val=baz.com:8080", + }, + { + desc: "$server_name with IPv6 and port", + src: "val=$server_name", + req: mustNewRequestWithHost(t, http.MethodGet, "http://[::1]:8080/foo", "[::1]:8080"), + expected: "val=::1", + }, + { + desc: "$server_port with IPv6 and port", + src: "val=$server_port", + req: mustNewRequestWithHost(t, http.MethodGet, "http://[::1]:8080/foo", "[::1]:8080"), + expected: "val=8080", + }, + { + desc: "$proxy_add_x_forwarded_for without existing header", + src: "val=$proxy_add_x_forwarded_for", + req: mustNewRequestWithRemoteAddr(t, http.MethodGet, "http://baz.com/foo", "192.168.1.1:12345"), + expected: "val=192.168.1.1", + }, + { + desc: "$proxy_add_x_forwarded_for with existing header", + src: "val=$proxy_add_x_forwarded_for", + req: func() *http.Request { + r := mustNewRequestWithRemoteAddr(t, http.MethodGet, "http://baz.com/foo", "10.0.0.1:9999") + r.Header.Set("X-Forwarded-For", "203.0.113.50, 70.41.3.18") + return r + }(), + expected: "val=203.0.113.50, 70.41.3.18, 10.0.0.1", + }, } for _, testCase := range testCases { t.Run(testCase.desc, func(t *testing.T) { t.Parallel() - got := ReplaceVariables(testCase.src, testCase.req) + got := ReplaceVariables(testCase.src, testCase.req, testCase.vars) require.Equal(t, testCase.expected, got) }) } @@ -105,3 +274,48 @@ func mustNewRequestWithHeaders(t *testing.T, method, target string, headers map[ return req } + +func mustNewRequestWithTLS(t *testing.T, method, target string) *http.Request { + t.Helper() + + req := httptest.NewRequest(method, target, http.NoBody) + req.TLS = &tls.ConnectionState{} + + return req +} + +func mustNewRequestWithRemoteAddr(t *testing.T, method, target, remoteAddr string) *http.Request { + t.Helper() + + req := httptest.NewRequest(method, target, http.NoBody) + req.RemoteAddr = remoteAddr + + return req +} + +func mustNewRequestWithHost(t *testing.T, method, target, host string) *http.Request { + t.Helper() + + req := httptest.NewRequest(method, target, http.NoBody) + req.Host = host + + return req +} + +func mustNewRequestWithCookie(t *testing.T, method, target, cookieName, cookieValue string) *http.Request { + t.Helper() + + req := httptest.NewRequest(method, target, http.NoBody) + req.AddCookie(&http.Cookie{Name: cookieName, Value: cookieValue}) + + return req +} + +func mustHostname(t *testing.T) string { + t.Helper() + + h, err := os.Hostname() + require.NoError(t, err) + + return h +} diff --git a/pkg/middlewares/ingressnginx/snippet/action.go b/pkg/middlewares/ingressnginx/snippet/action.go new file mode 100644 index 0000000000..647646bc66 --- /dev/null +++ b/pkg/middlewares/ingressnginx/snippet/action.go @@ -0,0 +1,1234 @@ +package snippet + +import ( + "errors" + "fmt" + "net" + "net/http" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/traefik/traefik/v3/pkg/middlewares/ingressnginx" + "github.com/tufanbarisyildirim/gonginx/config" +) + +// context holds variables set during request processing. +type actionContext struct { + vars map[string]string + nonMergeablePostActions map[string][]action + mergeablePostActions []action + + statusCode int + body string + redirectURL string + accessResolved bool + + stopCurrentBlock bool + stopAllDirectives bool +} + +func newContext(previousCtx *actionContext, a *actions) *actionContext { + unmergeablePostActions := a.nonMergeablePostActions + if a.nonMergeablePostActions == nil { + unmergeablePostActions = map[string][]action{} + } + + return &actionContext{ + vars: previousCtx.vars, + statusCode: previousCtx.statusCode, + body: previousCtx.body, + redirectURL: previousCtx.redirectURL, + nonMergeablePostActions: unmergeablePostActions, + mergeablePostActions: a.mergeablePostActions, + } +} + +func (c *actionContext) mergeWithSubContext(subCtx *actionContext) { + for directive, actions := range subCtx.nonMergeablePostActions { + if len(actions) > 0 { + c.nonMergeablePostActions[directive] = actions + } + } + + c.mergeablePostActions = append(c.mergeablePostActions, subCtx.mergeablePostActions...) + c.statusCode = subCtx.statusCode + c.body = subCtx.body + c.redirectURL = subCtx.redirectURL + + if subCtx.stopCurrentBlock { + c.stopCurrentBlock = true + } + if subCtx.stopAllDirectives { + c.stopAllDirectives = true + } +} + +type actions struct { + actions []action + mergeablePostActions []action + nonMergeablePostActions map[string][]action +} + +func (a *actions) Execute(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + // The actions struct can be nil if the middleware does not have a server snippet or a configuration snippet. + if a == nil { + return false, nil + } + + subCtx := newContext(ctx, a) + finish := false + for _, act := range a.actions { + var err error + finish, err = act(rw, req, subCtx) + if err != nil { + return false, err + } + if finish || subCtx.stopCurrentBlock || subCtx.stopAllDirectives { + break + } + } + + ctx.mergeWithSubContext(subCtx) + + return finish, nil +} + +// action is a function that applies a directive to the request/response. +// It returns true if the request should be interrupted (e.g. return directive). +// The context parameter allows actions to share state (e.g., variables set by 'set' directive). +type action func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) + +func buildActions(block config.IBlock) (*actions, error) { + acts := &actions{ + nonMergeablePostActions: make(map[string][]action), + } + for _, d := range block.GetDirectives() { + if err := isAllowedInContext(d); err != nil { + return nil, err + } + + switch d.GetName() { + case "add_header": + action, err := createAddHeaderAction(d) + if err != nil { + return nil, fmt.Errorf("creating add_header action: %w", err) + } + acts.nonMergeablePostActions[d.GetName()] = append(acts.nonMergeablePostActions[d.GetName()], action) + case "more_set_headers": + action, err := createMoreSetHeadersAction(d) + if err != nil { + return nil, fmt.Errorf("creating more_set_headers action: %w", err) + } + acts.mergeablePostActions = append(acts.mergeablePostActions, action) + case "proxy_set_header": + action, err := createProxySetHeaderAction(d) + if err != nil { + return nil, fmt.Errorf("creating proxy_set_header action: %w", err) + } + acts.nonMergeablePostActions[d.GetName()] = append(acts.nonMergeablePostActions[d.GetName()], action) + case "more_set_input_headers": + action, err := createMoreSetInputHeadersAction(d) + if err != nil { + return nil, fmt.Errorf("creating more_set_input_headers action: %w", err) + } + acts.mergeablePostActions = append(acts.mergeablePostActions, action) + case "more_clear_headers": + action, err := createMoreClearHeadersAction(d) + if err != nil { + return nil, fmt.Errorf("creating more_clear_headers action: %w", err) + } + acts.mergeablePostActions = append(acts.mergeablePostActions, action) + case "more_clear_input_headers": + action, err := createMoreClearInputHeadersAction(d) + if err != nil { + return nil, fmt.Errorf("creating more_clear_input_headers action: %w", err) + } + acts.mergeablePostActions = append(acts.mergeablePostActions, action) + case "if": + action, err := createIfAction(d) + if err != nil { + return nil, fmt.Errorf("creating if action: %w", err) + } + acts.actions = append(acts.actions, action) + case "set": + action, err := createSetAction(d) + if err != nil { + return nil, fmt.Errorf("creating set action: %w", err) + } + acts.actions = append(acts.actions, action) + case "return": + action, err := createReturnAction(d) + if err != nil { + return nil, fmt.Errorf("creating return action: %w", err) + } + acts.actions = append(acts.actions, action) + case "rewrite": + action, err := createRewriteAction(d) + if err != nil { + return nil, fmt.Errorf("creating rewrite action: %w", err) + } + acts.actions = append(acts.actions, action) + case "location": + action, err := createLocationAction(d) + if err != nil { + return nil, fmt.Errorf("creating location action: %w", err) + } + acts.actions = append(acts.actions, action) + case "allow", "deny": + action, err := createAccessAction(d) + if err != nil { + return nil, fmt.Errorf("creating %s action: %w", d.GetName(), err) + } + acts.actions = append(acts.actions, action) + case "proxy_hide_header": + action, err := createProxyHideHeaderAction(d) + if err != nil { + return nil, fmt.Errorf("creating proxy_hide_header action: %w", err) + } + acts.mergeablePostActions = append(acts.mergeablePostActions, action) + case "expires": + action, err := createExpiresAction(d) + if err != nil { + return nil, fmt.Errorf("creating expires action: %w", err) + } + acts.mergeablePostActions = append(acts.mergeablePostActions, action) + default: + return nil, fmt.Errorf("unsupported directive %q", d.GetName()) + } + } + + return acts, nil +} + +// addHeaderStatusCodes lists the status codes for which add_header is effective +// when the "always" parameter is NOT specified. +var addHeaderStatusCodes = []int{ + 200, 201, 204, 206, + 301, 302, 303, 304, + 307, 308, +} + +func createAddHeaderAction(d config.IDirective) (action, error) { + params := d.GetParameters() + if len(params) < 2 { + return nil, errors.New("add_header directive must have at least 2 parameters (header and value)") + } + + key := params[0].String() + val := trimQuote(params[1].String()) + + // Check for the "always" flag (third parameter). + var always bool + if len(params) >= 3 && params[2].String() == "always" { + always = true + } + + if always { + // With "always", the header is added regardless of response status code. + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + rw.Header().Add(key, ingressnginx.ReplaceVariables(val, req, ctx.vars)) + return false, nil + }, nil + } + + // Without "always", register a deferred operation that checks the status code. + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + if wrapper, ok := rw.(*snippetResponseWriter); ok { + resolvedVal := ingressnginx.ReplaceVariables(val, req, ctx.vars) + wrapper.onWriteHeader = append(wrapper.onWriteHeader, func(code int, h http.Header) { + if slices.Contains(addHeaderStatusCodes, code) { + h.Add(key, resolvedVal) + } + }) + } else { + // Fallback: add unconditionally. + rw.Header().Add(key, ingressnginx.ReplaceVariables(val, req, ctx.vars)) + } + return false, nil + }, nil +} + +// directiveFlags holds parsed flags for headers-more directives (-s, -t, -a, -r). +type directiveFlags struct { + statusCodes []int + contentTypes []string + appendMode bool + restrictOnly bool +} + +// parseDirectiveFlags parses -s, -t, -a, -r flags from directive parameters. +// It returns the flags and the remaining (non-flag) parameters. +// The allowStatusFilter parameter controls whether the -s flag is accepted; +// in the real headers-more module, -s is only valid on output header directives. +func parseDirectiveFlags(params []config.Parameter, allowStatusFilter bool) (directiveFlags, []config.Parameter, error) { + var flags directiveFlags + var remaining []config.Parameter + + i := 0 + for i < len(params) { + p := params[i].String() + switch p { + case "-s": + if !allowStatusFilter { + return flags, nil, errors.New("flag -s is not supported for this directive") + } + i++ + if i < len(params) { + for s := range strings.FieldsSeq(trimQuote(params[i].String())) { + code, err := strconv.Atoi(s) + if err == nil && code > 0 { + flags.statusCodes = append(flags.statusCodes, code) + } + } + } + case "-t": + i++ + if i < len(params) { + flags.contentTypes = append(flags.contentTypes, strings.Fields(trimQuote(params[i].String()))...) + } + case "-a": + flags.appendMode = true + case "-r": + flags.restrictOnly = true + default: + remaining = append(remaining, params[i]) + } + i++ + } + + return flags, remaining, nil +} + +// matchesStatusFilter returns true if the code matches the filter (or filter is empty). +func matchesStatusFilter(filter []int, code int) bool { + if len(filter) == 0 { + return true + } + return slices.Contains(filter, code) +} + +// matchesContentTypeFilter returns true if the contentType matches the filter (or filter is empty). +func matchesContentTypeFilter(filter []string, contentType string) bool { + if len(filter) == 0 { + return true + } + // Extract base MIME type (without parameters like charset). + base, _, _ := strings.Cut(contentType, ";") + base = strings.TrimSpace(base) + for _, f := range filter { + if strings.EqualFold(base, f) { + return true + } + } + return false +} + +func createMoreSetHeadersAction(d config.IDirective) (action, error) { + flags, remaining, err := parseDirectiveFlags(d.GetParameters(), true) + if err != nil { + return nil, fmt.Errorf("parsing more_set_headers flags: %w", err) + } + ops, err := parseMoreSetParams(remaining, "more_set_headers") + if err != nil { + return nil, fmt.Errorf("parsing more_set_headers directive: %w", err) + } + + hasFilters := len(flags.statusCodes) > 0 || len(flags.contentTypes) > 0 + + if hasFilters { + // Deferred: register a hook on the response writer to execute when + // the status code is known. + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + if wrapper, ok := rw.(*snippetResponseWriter); ok { + wrapper.onWriteHeader = append(wrapper.onWriteHeader, func(code int, h http.Header) { + ct := h.Get("Content-Type") + if !matchesStatusFilter(flags.statusCodes, code) || !matchesContentTypeFilter(flags.contentTypes, ct) { + return + } + applyHeaderOps(h, nil, ops, flags, req, ctx) + }) + } + return false, nil + }, nil + } + + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + applyHeaderOps(rw.Header(), nil, ops, flags, req, ctx) + return false, nil + }, nil +} + +func createMoreSetInputHeadersAction(d config.IDirective) (action, error) { + flags, remaining, err := parseDirectiveFlags(d.GetParameters(), false) + if err != nil { + return nil, fmt.Errorf("parsing more_set_input_headers flags: %w", err) + } + ops, err := parseMoreSetParams(remaining, "more_set_input_headers") + if err != nil { + return nil, fmt.Errorf("parsing more_set_input_headers directive: %w", err) + } + + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + // -t filter on request-side: check request Content-Type. + if len(flags.contentTypes) > 0 && !matchesContentTypeFilter(flags.contentTypes, req.Header.Get("Content-Type")) { + return false, nil + } + applyHeaderOps(nil, req, ops, flags, req, ctx) + return false, nil + }, nil +} + +// applyHeaderOps applies header operations to either response headers (h) or request headers (req). +// If h is non-nil, operations apply to response headers. Otherwise, operations apply to req.Header. +func applyHeaderOps(h http.Header, r *http.Request, ops []headerOp, flags directiveFlags, req *http.Request, ctx *actionContext) { + target := h + if target == nil && r != nil { + target = r.Header + } + if target == nil { + return + } + + for _, op := range ops { + if op.clear { + target.Del(op.key) + continue + } + + // -r flag: only set if the header already exists. + if flags.restrictOnly && target.Get(op.key) == "" { + continue + } + + resolvedVal := ingressnginx.ReplaceVariables(op.value, req, ctx.vars) + if flags.appendMode { + target.Add(op.key, resolvedVal) + } else { + target.Set(op.key, resolvedVal) + } + } +} + +// headerClearOp represents a header to clear, with optional wildcard matching. +type headerClearOp struct { + name string // exact header name or prefix (without trailing *) + wildcard bool // true if the original name ended with * +} + +func createMoreClearHeadersAction(d config.IDirective) (action, error) { + flags, remaining, err := parseDirectiveFlags(d.GetParameters(), true) + if err != nil { + return nil, fmt.Errorf("parsing more_clear_headers flags: %w", err) + } + ops, err := parseMoreClearParams(remaining, "more_clear_headers") + if err != nil { + return nil, fmt.Errorf("parsing more_clear_headers directive: %w", err) + } + + hasFilters := len(flags.statusCodes) > 0 || len(flags.contentTypes) > 0 + + if hasFilters { + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + if wrapper, ok := rw.(*snippetResponseWriter); ok { + wrapper.onWriteHeader = append(wrapper.onWriteHeader, func(code int, h http.Header) { + ct := h.Get("Content-Type") + if !matchesStatusFilter(flags.statusCodes, code) || !matchesContentTypeFilter(flags.contentTypes, ct) { + return + } + clearHeaders(h, ops) + }) + } + return false, nil + }, nil + } + + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + clearHeaders(rw.Header(), ops) + return false, nil + }, nil +} + +func createMoreClearInputHeadersAction(d config.IDirective) (action, error) { + flags, remaining, err := parseDirectiveFlags(d.GetParameters(), false) + if err != nil { + return nil, fmt.Errorf("parsing more_clear_input_headers flags: %w", err) + } + ops, err := parseMoreClearParams(remaining, "more_clear_input_headers") + if err != nil { + return nil, fmt.Errorf("parsing more_clear_input_headers directive: %w", err) + } + + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + if len(flags.contentTypes) > 0 && !matchesContentTypeFilter(flags.contentTypes, req.Header.Get("Content-Type")) { + return false, nil + } + clearHeaders(req.Header, ops) + return false, nil + }, nil +} + +// parseMoreClearParams parses header names from the remaining (non-flag) parameters. +func parseMoreClearParams(params []config.Parameter, directiveName string) ([]headerClearOp, error) { + if len(params) == 0 { + return nil, fmt.Errorf("%s directive must have at least 1 header name", directiveName) + } + + ops := make([]headerClearOp, 0, len(params)) + for _, p := range params { + name := trimQuote(p.String()) + if name == "" { + return nil, fmt.Errorf("%s directive has an empty header name", directiveName) + } + + if prefix, ok := strings.CutSuffix(name, "*"); ok { + if prefix == "" { + return nil, fmt.Errorf("%s directive has an invalid wildcard pattern %q", directiveName, name) + } + ops = append(ops, headerClearOp{name: prefix, wildcard: true}) + } else { + ops = append(ops, headerClearOp{name: name}) + } + } + + return ops, nil +} + +// clearHeaders removes headers from the given http.Header map based on the +// provided clear operations. For exact matches it uses Del; for wildcard +// matches it iterates all headers and removes those whose name starts with +// the given prefix (case-insensitive). +func clearHeaders(h http.Header, ops []headerClearOp) { + for _, op := range ops { + if op.wildcard { + lowerPrefix := strings.ToLower(op.name) + for name := range h { + if strings.HasPrefix(strings.ToLower(name), lowerPrefix) { + h.Del(name) + } + } + } else { + h.Del(op.name) + } + } +} + +// headerOp represents a single header operation: either setting a header to a +// value or clearing (deleting) a header. +type headerOp struct { + key string + value string + clear bool +} + +// parseMoreSetParams parses one or more quoted "Key: Value" parameters +// from the remaining (non-flag) parameters. It supports: +// - Multiple parameters: more_set_headers "H1: v1" "H2: v2"; +// - Header clearing: "Key:", "Key: ", or "Key" (no colon) all result in a +// clear operation that deletes the header. +func parseMoreSetParams(params []config.Parameter, directiveName string) ([]headerOp, error) { + if len(params) == 0 { + return nil, fmt.Errorf("%s directive must have at least 1 parameter", directiveName) + } + + ops := make([]headerOp, 0, len(params)) + for _, p := range params { + trimmed := trimQuote(p.String()) + if trimmed == "" { + return nil, fmt.Errorf("%s directive has an empty parameter", directiveName) + } + + parts := strings.SplitN(trimmed, ":", 2) + key := strings.TrimSpace(parts[0]) + if key == "" { + return nil, fmt.Errorf("%s directive has an empty header name", directiveName) + } + + if len(parts) == 1 { + // No colon found — this is a clear operation. + ops = append(ops, headerOp{key: key, clear: true}) + continue + } + + value := strings.TrimSpace(parts[1]) + if value == "" { + // Colon present but value is empty — clear operation. + ops = append(ops, headerOp{key: key, clear: true}) + continue + } + + ops = append(ops, headerOp{key: key, value: value}) + } + + return ops, nil +} + +func createProxySetHeaderAction(d config.IDirective) (action, error) { + params := d.GetParameters() + if len(params) < 2 { + return nil, errors.New("proxy_set_header directive requires 2 parameters (header and value)") + } + + key := params[0].String() + val := trimQuote(params[1].String()) + + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + resolved := ingressnginx.ReplaceVariables(val, req, ctx.vars) + if resolved == "" { + req.Header.Del(key) + } else { + req.Header.Set(key, resolved) + } + return false, nil + }, nil +} + +// createProxyHideHeaderAction hides the specified header from the upstream response. +// It registers a deferred hook on the response writer to delete the header when +// the response status is being written. +func createProxyHideHeaderAction(d config.IDirective) (action, error) { + params := d.GetParameters() + if len(params) == 0 { + return nil, errors.New("proxy_hide_header directive requires 1 parameter (header name)") + } + + headerName := trimQuote(params[0].String()) + + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + if wrapper, ok := rw.(*snippetResponseWriter); ok { + wrapper.onWriteHeader = append(wrapper.onWriteHeader, func(_ int, h http.Header) { + h.Del(headerName) + }) + } + return false, nil + }, nil +} + +// createAccessAction creates an action for the allow or deny directive. +// Rules are evaluated in order until the first match is found (first match wins). +// If a deny rule matches, it returns 403. If an allow rule matches, processing continues. +func createAccessAction(d config.IDirective) (action, error) { + params := d.GetParameters() + if len(params) == 0 { + return nil, fmt.Errorf("%s directive requires 1 parameter", d.GetName()) + } + + isAllow := d.GetName() == "allow" + addr := trimQuote(params[0].String()) + + if addr == "all" { + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + if ctx.accessResolved { + return false, nil + } + ctx.accessResolved = true + if !isAllow { + ctx.statusCode = http.StatusForbidden + ctx.body = "403 Forbidden" + return true, nil + } + return false, nil + }, nil + } + + // Parse IP or CIDR. + var ipNet *net.IPNet + if strings.Contains(addr, "/") { + _, parsed, err := net.ParseCIDR(addr) + if err != nil { + return nil, fmt.Errorf("invalid CIDR in %s directive: %w", d.GetName(), err) + } + ipNet = parsed + } else { + ip := net.ParseIP(addr) + if ip == nil { + return nil, fmt.Errorf("invalid IP address in %s directive: %q", d.GetName(), addr) + } + // Single IP: create a /32 or /128 mask. + bits := 32 + if ip.To4() == nil { + bits = 128 + } + ipNet = &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)} + } + + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + if ctx.accessResolved { + return false, nil + } + + remoteIP := extractIP(req.RemoteAddr) + if remoteIP == nil { + return false, nil + } + + if !ipNet.Contains(remoteIP) { + return false, nil + } + + // First matching rule wins. + ctx.accessResolved = true + if !isAllow { + ctx.statusCode = http.StatusForbidden + ctx.body = "403 Forbidden" + return true, nil + } + return false, nil + }, nil +} + +// extractIP parses a remote address (potentially with port) and returns the IP. +func extractIP(remoteAddr string) net.IP { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + // Try parsing as bare IP. + return net.ParseIP(remoteAddr) + } + return net.ParseIP(host) +} + +// createExpiresAction implements the NGINX expires directive. +// Syntax: expires time | epoch | max | off; +// Sets "Expires" and "Cache-Control" response headers. +func createExpiresAction(d config.IDirective) (action, error) { + params := d.GetParameters() + if len(params) == 0 { + return nil, errors.New("expires directive requires 1 parameter") + } + + value := trimQuote(params[0].String()) + + switch value { + case "off": + // No-op: don't set any cache headers. + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + return false, nil + }, nil + + case "epoch": + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + rw.Header().Set("Expires", "Thu, 01 Jan 1970 00:00:01 GMT") + rw.Header().Set("Cache-Control", "no-cache") + return false, nil + }, nil + + case "max": + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + rw.Header().Set("Expires", "Thu, 31 Dec 2037 23:55:55 GMT") + rw.Header().Set("Cache-Control", "max-age=315360000") + return false, nil + }, nil + } + + // Parse as duration. NGINX uses a custom format but common values are like "24h", "30d", "1y", "-1". + dur, err := parseNginxDuration(value) + if err != nil { + return nil, fmt.Errorf("invalid expires value %q: %w", value, err) + } + + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + if dur < 0 { + rw.Header().Set("Expires", time.Now().Add(dur).UTC().Format(http.TimeFormat)) + rw.Header().Set("Cache-Control", "no-cache") + } else { + rw.Header().Set("Expires", time.Now().Add(dur).UTC().Format(http.TimeFormat)) + rw.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", int(dur.Seconds()))) + } + return false, nil + }, nil +} + +// parseNginxDuration parses NGINX-style duration strings: "30s", "5m", "24h", "7d", "1y", "-1". +func parseNginxDuration(s string) (time.Duration, error) { + if s == "" { + return 0, errors.New("empty duration") + } + + negative := false + if s[0] == '-' { + negative = true + s = s[1:] + } + + // Try standard Go duration first (handles "5s", "10m", "24h" etc.). + dur, err := time.ParseDuration(s) + if err == nil { + if negative { + dur = -dur + } + return dur, nil + } + + // Handle NGINX-specific suffixes: "d" (days), "M" (months≈30d), "y" (years≈365d). + if len(s) > 1 { + numStr := s[:len(s)-1] + suffix := s[len(s)-1] + num, numErr := strconv.Atoi(numStr) + if numErr == nil { + var d time.Duration + switch suffix { + case 'd': + d = time.Duration(num) * 24 * time.Hour + case 'M': + d = time.Duration(num) * 30 * 24 * time.Hour + case 'y': + d = time.Duration(num) * 365 * 24 * time.Hour + default: + return 0, fmt.Errorf("unsupported duration suffix %q", string(suffix)) + } + if negative { + d = -d + } + return d, nil + } + } + + // Try bare number (seconds in NGINX). + num, numErr := strconv.Atoi(s) + if numErr == nil { + d := time.Duration(num) * time.Second + if negative { + d = -d + } + return d, nil + } + + return 0, fmt.Errorf("cannot parse duration %q", s) +} + +func createIfAction(d config.IDirective) (action, error) { + params := d.GetParameters() + if len(params) == 0 { + return nil, errors.New("if directive requires a condition") + } + + // Parse condition - simplified implementation. + // Supports: ($var = value), ($var != value), ($var), ($request_method = METHOD). + var paramStrs []string + for _, p := range params { + paramStrs = append(paramStrs, p.String()) + } + condition := strings.Join(paramStrs, " ") + condition = strings.Trim(condition, "()") + + // Build actions from the if block. + block := d.GetBlock() + if block == nil { + return nil, errors.New("if directive requires a block") + } + + blockActions, err := buildActions(block) + if err != nil { + return nil, fmt.Errorf("building if block actions: %w", err) + } + + eval, err := buildCondition(condition) + if err != nil { + return nil, fmt.Errorf("building if condition: %w", err) + } + + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + if eval(req, ctx) { + return blockActions.Execute(rw, req, ctx) + } + return false, nil + }, nil +} + +// isRedirectCode returns true if the given HTTP status code is a redirect code +// that NGINX treats as requiring a Location header (301, 302, 303, 307, 308). +func isRedirectCode(code int) bool { + switch code { + case http.StatusMovedPermanently, + http.StatusFound, + http.StatusSeeOther, + http.StatusTemporaryRedirect, + http.StatusPermanentRedirect: + return true + } + return false +} + +// parseIntSimple returns the parsed integer and true, or zero and false. +func parseIntSimple(s string) (int, bool) { + n, err := strconv.Atoi(s) + if err != nil { + return 0, false + } + return n, true +} + +func createReturnURLAction(u string) action { + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + resolvedURL := ingressnginx.ReplaceVariables(u, req, ctx.vars) + ctx.statusCode = http.StatusFound + ctx.redirectURL = resolvedURL + return true, nil + } +} + +// createReturnAction implements the NGINX return directive. +// Syntax: return code [text]; | return code URL; | return URL; +// For redirect codes (301, 302, 303, 307, 308), the second parameter is treated as a URL +// and a Location header is set. For other codes, it is treated as response body text. +func createReturnAction(d config.IDirective) (action, error) { + params := d.GetParameters() + if len(params) == 0 { + return nil, errors.New("return directive requires parameters") + } + + first := params[0].String() + + // If the first parameter is not a number, it's a URL (return URL; syntax = 302 redirect). + code, isNumeric := parseIntSimple(first) + if !isNumeric { + return createReturnURLAction(trimQuote(first)), nil + } + + var text string + if len(params) > 1 { + text = trimQuote(params[1].String()) + } + + if isRedirectCode(code) { + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + ctx.statusCode = code + if text != "" { + ctx.redirectURL = ingressnginx.ReplaceVariables(text, req, ctx.vars) + } + return true, nil + }, nil + } + + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + ctx.statusCode = code + if text != "" { + ctx.body = ingressnginx.ReplaceVariables(text, req, ctx.vars) + } + return true, nil + }, nil +} + +// captureGroupRegexp matches $1 through $9 capture group references in replacement strings. +var captureGroupRegexp = regexp.MustCompile(`\$([1-9])`) + +// replaceCaptureGroups replaces $1-$9 references in the replacement string with +// the corresponding capture group values from the regex match. +func replaceCaptureGroups(replacement string, matches []string) string { + return captureGroupRegexp.ReplaceAllStringFunc(replacement, func(ref string) string { + idx := int(ref[1] - '0') + if idx < len(matches) { + return matches[idx] + } + return ref + }) +} + +// createRewriteAction implements the NGINX rewrite directive. +// Syntax: rewrite regex replacement [flag]; +// Flags: last, break, redirect, permanent. +// If the replacement starts with http://, https://, or $scheme, a redirect is returned. +// If the replacement ends with ?, the original query string is not appended. +func createRewriteAction(d config.IDirective) (action, error) { + params := d.GetParameters() + if len(params) < 2 { + return nil, errors.New("rewrite directive requires at least 2 parameters (regex and replacement)") + } + + pattern := params[0].String() + replacement := params[1].String() + + var flag string + if len(params) >= 3 { + flag = params[2].String() + } + + // Validate the flag. + switch flag { + case "", "last", "break", "redirect", "permanent": + default: + return nil, fmt.Errorf("rewrite directive has invalid flag %q", flag) + } + + // Compile the regex at build time. + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("compiling rewrite regex: %w", err) + } + + // Determine if the ? suffix is present to suppress query string appending. + suppressQueryString := strings.HasSuffix(replacement, "?") + if suppressQueryString { + replacement = replacement[:len(replacement)-1] + } + + // Determine if this is a redirect based on the replacement or flag. + isURLRedirect := strings.HasPrefix(replacement, "http://") || + strings.HasPrefix(replacement, "https://") || + strings.HasPrefix(replacement, "$scheme") + + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + matches := re.FindStringSubmatch(req.URL.Path) + if matches == nil { + return false, nil + } + + // Build the replacement string: first replace capture groups, then NGINX variables. + result := replaceCaptureGroups(replacement, matches) + result = ingressnginx.ReplaceVariables(result, req, ctx.vars) + + // Determine redirect behavior. + switch { + case flag == "redirect": + ctx.statusCode = http.StatusFound + ctx.redirectURL = result + return true, nil + + case flag == "permanent": + ctx.statusCode = http.StatusMovedPermanently + ctx.redirectURL = result + return true, nil + + case isURLRedirect: + // Replacement starts with http://, https://, or $scheme: default to 302. + ctx.statusCode = http.StatusFound + ctx.redirectURL = result + return true, nil + } + + // Not a redirect: rewrite the request URI in place. + if path, query, ok := strings.Cut(result, "?"); ok { + // Replacement contains a query string. + req.URL.Path = path + req.URL.RawQuery = query + } else { + req.URL.Path = result + if suppressQueryString { + req.URL.RawQuery = "" + } + // Otherwise, keep the original query string. + } + + req.RequestURI = req.URL.RequestURI() + + // In NGINX, last restarts location matching while break stays in the + // current location. In Traefik's middleware model, last stops + // processing the current block (allowing subsequent blocks to run), + // while break stops all remaining directive processing. + // Both forward the rewritten request to the upstream. + if flag == "last" { + ctx.stopCurrentBlock = true + return false, nil + } + if flag == "break" { + ctx.stopAllDirectives = true + return false, nil + } + + return false, nil + }, nil +} + +func createSetAction(d config.IDirective) (action, error) { + params := d.GetParameters() + if len(params) < 2 { + return nil, errors.New("set directive requires 2 parameters (variable and value)") + } + + varName := params[0].String() + value := trimQuote(params[1].String()) + + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + ctx.vars[varName] = ingressnginx.ReplaceVariables(value, req, ctx.vars) + return false, nil + }, nil +} + +func createLocationAction(d config.IDirective) (action, error) { + params := d.GetParameters() + if len(params) == 0 { + return nil, errors.New("location directive requires a path pattern") + } + + pathPattern := params[0].String() + if len(params) == 2 { + pathPattern += params[1].String() + } + + // Build actions from the location block. + block := d.GetBlock() + if block == nil { + return nil, errors.New("location directive requires a block") + } + + blockActions, err := buildActions(block) + if err != nil { + return nil, fmt.Errorf("building location block actions: %w", err) + } + + // Determine location type and compile regex if needed. + // Check ~* (case-insensitive regex) before ~ (case-sensitive regex). + var re *regexp.Regexp + var isExact bool + if pattern, ok := strings.CutPrefix(pathPattern, "~*"); ok { + pattern = strings.TrimSpace(pattern) + re, err = regexp.Compile("(?i)" + pattern) + if err != nil { + return nil, fmt.Errorf("compiling location regex: %w", err) + } + } else if pattern, ok := strings.CutPrefix(pathPattern, "~"); ok { + pattern = strings.TrimSpace(pattern) + re, err = regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("compiling location regex: %w", err) + } + } else if exact, ok := strings.CutPrefix(pathPattern, "="); ok { + pathPattern = strings.TrimSpace(exact) + isExact = true + } + + return func(rw http.ResponseWriter, req *http.Request, ctx *actionContext) (bool, error) { + var matches bool + switch { + case re != nil: + matches = re.MatchString(req.URL.Path) + case isExact: + matches = req.URL.Path == pathPattern + default: + matches = strings.HasPrefix(req.URL.Path, pathPattern) + } + + if matches { + stop, err := blockActions.Execute(rw, req, ctx) + if err != nil { + return false, fmt.Errorf("executing location block: %w", err) + } + + return stop, nil + } + return false, nil + }, nil +} + +// conditionEvaluator is a function that evaluates a parsed if-condition at request time. +type conditionEvaluator func(req *http.Request, ctx *actionContext) bool + +// buildCondition pre-parses an if-condition string and returns a conditionEvaluator. +// It pre-compiles regexes at build time instead of on every request. +// Supports: ($var), ($var = value), ($var != value), ($var ~ regex), ($var !~ regex), +// ($var ~* regex), ($var !~* regex). +func buildCondition(condition string) (conditionEvaluator, error) { + parts := strings.Fields(condition) + if len(parts) == 0 { + return nil, errors.New("empty condition") + } + + // Simple variable check: if ($var). + // Uses ReplaceVariables so both custom and built-in variables work. + if len(parts) == 1 { + varExpr := parts[0] + return func(req *http.Request, ctx *actionContext) bool { + val := ingressnginx.ReplaceVariables(varExpr, req, ctx.vars) + // If the variable was not resolved, treat as undefined. + if val == varExpr { + return false + } + return val != "" && val != "0" + }, nil + } + + if len(parts) < 3 { + return nil, fmt.Errorf("invalid condition format: %s", condition) + } + + varExpr := strings.Trim(parts[0], `"`) + operator := parts[1] + expectedExpr := strings.Trim(parts[2], `"`) + + switch operator { + case "=": + return func(req *http.Request, ctx *actionContext) bool { + varVal := ingressnginx.ReplaceVariables(varExpr, req, ctx.vars) + expected := ingressnginx.ReplaceVariables(expectedExpr, req, ctx.vars) + return varVal == expected + }, nil + + case "!=": + return func(req *http.Request, ctx *actionContext) bool { + varVal := ingressnginx.ReplaceVariables(varExpr, req, ctx.vars) + expected := ingressnginx.ReplaceVariables(expectedExpr, req, ctx.vars) + return varVal != expected + }, nil + + case "~": + re, err := regexp.Compile(expectedExpr) + if err != nil { + return nil, fmt.Errorf("compiling regex in condition: %w", err) + } + return func(req *http.Request, ctx *actionContext) bool { + varVal := ingressnginx.ReplaceVariables(varExpr, req, ctx.vars) + matches := re.FindStringSubmatch(varVal) + if matches == nil { + return false + } + storeCaptureGroups(ctx, matches) + return true + }, nil + + case "!~": + re, err := regexp.Compile(expectedExpr) + if err != nil { + return nil, fmt.Errorf("compiling regex in condition: %w", err) + } + return func(req *http.Request, ctx *actionContext) bool { + varVal := ingressnginx.ReplaceVariables(varExpr, req, ctx.vars) + return !re.MatchString(varVal) + }, nil + + case "~*": + re, err := regexp.Compile("(?i)" + expectedExpr) + if err != nil { + return nil, fmt.Errorf("compiling case-insensitive regex in condition: %w", err) + } + return func(req *http.Request, ctx *actionContext) bool { + varVal := ingressnginx.ReplaceVariables(varExpr, req, ctx.vars) + matches := re.FindStringSubmatch(varVal) + if matches == nil { + return false + } + storeCaptureGroups(ctx, matches) + return true + }, nil + + case "!~*": + re, err := regexp.Compile("(?i)" + expectedExpr) + if err != nil { + return nil, fmt.Errorf("compiling case-insensitive regex in condition: %w", err) + } + return func(req *http.Request, ctx *actionContext) bool { + varVal := ingressnginx.ReplaceVariables(varExpr, req, ctx.vars) + return !re.MatchString(varVal) + }, nil + + default: + return nil, fmt.Errorf("unsupported operator in condition: %s", operator) + } +} + +// storeCaptureGroups stores regex capture groups ($1-$9) in the action context. +func storeCaptureGroups(ctx *actionContext, matches []string) { + for i := 1; i < len(matches); i++ { + ctx.vars[fmt.Sprintf("$%d", i)] = matches[i] + } +} + +func trimQuote(val string) string { + if len(val) > 1 { + if val[0] == '"' && val[len(val)-1] == '"' { + return val[1 : len(val)-1] + } + } + return val +} diff --git a/pkg/middlewares/ingressnginx/snippet/directive_context.go b/pkg/middlewares/ingressnginx/snippet/directive_context.go new file mode 100644 index 0000000000..e803bad28e --- /dev/null +++ b/pkg/middlewares/ingressnginx/snippet/directive_context.go @@ -0,0 +1,53 @@ +package snippet + +import ( + "fmt" + "slices" + + "github.com/tufanbarisyildirim/gonginx/config" +) + +const ( + contextServer = "server" + contextLocation = "location" + contextIf = "if" + contextIfInLocation = "if_in_location" +) + +var directiveContexts = map[string][]string{ + "add_header": {contextServer, contextLocation, contextIfInLocation}, + "more_set_headers": {contextServer, contextLocation, contextIf}, + "more_clear_headers": {contextServer, contextLocation, contextIf}, + "proxy_set_header": {contextServer, contextLocation}, + "more_set_input_headers": {contextServer, contextLocation, contextIf}, + "more_clear_input_headers": {contextServer, contextLocation, contextIf}, + "if": {contextServer, contextLocation}, + "set": {contextServer, contextLocation, contextIf}, + "return": {contextServer, contextLocation, contextIf}, + "rewrite": {contextServer, contextLocation, contextIf}, + "location": {contextServer}, + "allow": {contextServer, contextLocation, contextIf}, + "deny": {contextServer, contextLocation, contextIf}, + "proxy_hide_header": {contextServer, contextLocation}, + "expires": {contextServer, contextLocation, contextIfInLocation}, +} + +// isAllowedInContext checks if the directive is allowed in the context of its parent directive. +func isAllowedInContext(directive config.IDirective) error { + ctx := directive.GetParent().GetName() + allowedCtxs := directiveContexts[directive.GetName()] + + if slices.Contains(allowedCtxs, ctx) { + return nil + } + + if slices.Contains(allowedCtxs, contextIfInLocation) && ctx == contextIf { + // Here we are checking if the parent of the "if" directive is a "location" directive, + // which means that the "if" directive is inside a "location" block. + if directive.GetParent().GetParent().GetName() == contextLocation { + return nil + } + } + + return fmt.Errorf("context %s is not valid for this directive %s: %+v ", ctx, directive.GetName(), allowedCtxs) +} diff --git a/pkg/middlewares/ingressnginx/snippet/snippet.go b/pkg/middlewares/ingressnginx/snippet/snippet.go new file mode 100644 index 0000000000..57d26bbe8e --- /dev/null +++ b/pkg/middlewares/ingressnginx/snippet/snippet.go @@ -0,0 +1,232 @@ +package snippet + +import ( + "bufio" + "context" + "errors" + "fmt" + "net" + "net/http" + + "github.com/traefik/traefik/v3/pkg/config/dynamic" + "github.com/traefik/traefik/v3/pkg/middlewares" + "github.com/tufanbarisyildirim/gonginx/parser" +) + +const typeName = "Snippet" + +// Snippet is a middleware allowing to parse and interpret NGINX snippets containing directives. +type Snippet struct { + next http.Handler + name string + serverActions *actions + configurationActions *actions +} + +// New creates a new Snippet middleware instance. +// It parses the provided snippets and builds the corresponding actions. +func New(ctx context.Context, next http.Handler, config *dynamic.Snippet, name string) (h http.Handler, err error) { + // Here we are adding a recover block as the snippet parsing can panic. + defer func() { + if recErr := recover(); recErr != nil { + err = fmt.Errorf("snippet parsing recover: %v", recErr) + } + }() + + logger := middlewares.GetLogger(ctx, name, typeName) + logger.Debug().Msg("Creating middleware") + + if config.ServerSnippet == "" && config.ConfigurationSnippet == "" { + return nil, errors.New("at least one of serverSnippet or configurationSnippet option must be provided") + } + + parserOptions := []parser.Option{ + parser.WithSkipComments(), + parser.WithCustomDirectives("more_set_headers", "more_set_input_headers", "more_clear_headers", "more_clear_input_headers", "proxy_hide_header"), + } + + var serverActions *actions + if config.ServerSnippet != "" { + // Parse the snippet, note that we are wrapping the server snippet in a server block to ensure that it is parsed in the correct context. + p := parser.NewStringParser(fmt.Sprintf("server{%s}", config.ServerSnippet), parserOptions...) + + conf, parseErr := p.Parse() + if parseErr != nil { + return nil, fmt.Errorf("parsing server-snippet: %w", parseErr) + } + + serverActions, err = buildActions(conf.GetDirectives()[0].GetBlock()) + if err != nil { + return nil, fmt.Errorf("building actions from server-snippet: %w", err) + } + } + + var ( + buildErr error + configurationActions *actions + ) + if config.ConfigurationSnippet != "" { + // Parse the snippet, note that we are wrapping the configuration snippet in a location block to ensure that it is parsed in the correct context. + p := parser.NewStringParser(fmt.Sprintf("location / {%s}", config.ConfigurationSnippet), parserOptions...) + + conf, parseErr := p.Parse() + if parseErr != nil { + return nil, fmt.Errorf("parsing configuration-snippet: %w", parseErr) + } + + configurationActions, buildErr = buildActions(conf.GetDirectives()[0].GetBlock()) + if buildErr != nil { + return nil, fmt.Errorf("building actions from configuration-snippet: %w", buildErr) + } + } + + return &Snippet{ + next: next, + name: name, + serverActions: serverActions, + configurationActions: configurationActions, + }, nil +} + +func (s *Snippet) GetTracingInformation() (string, string) { + return s.name, typeName +} + +func (s *Snippet) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + wrappedRW := &snippetResponseWriter{ResponseWriter: rw} + + ctx := &actionContext{ + vars: make(map[string]string), + nonMergeablePostActions: make(map[string][]action), + } + + stop, err := s.serverActions.Execute(wrappedRW, req, ctx) + if err != nil { + http.Error(wrappedRW, err.Error(), http.StatusInternalServerError) + return + } + if stop { + if err = executePostActions(wrappedRW, req, ctx); err != nil { + http.Error(wrappedRW, err.Error(), http.StatusInternalServerError) + return + } + + writeResponse(wrappedRW, req, ctx) + return + } + + // In NGINX, proxy_set_header directives in the server snippet are ignored + // because the generated location block always contains proxy_set_header directives that override them. + ctx.nonMergeablePostActions["proxy_set_header"] = nil + + // rewrite...break in the server snippet stops all directive processing, + // but post-actions (headers, etc.) and upstream forwarding still proceed. + if !ctx.stopAllDirectives { + ctx.stopCurrentBlock = false + + stop, err = s.configurationActions.Execute(wrappedRW, req, ctx) + if err != nil { + http.Error(wrappedRW, err.Error(), http.StatusInternalServerError) + return + } + } + + if err = executePostActions(wrappedRW, req, ctx); err != nil { + http.Error(wrappedRW, err.Error(), http.StatusInternalServerError) + return + } + + if stop { + writeResponse(wrappedRW, req, ctx) + return + } + + s.next.ServeHTTP(wrappedRW, req) +} + +// snippetResponseWriter wraps http.ResponseWriter to intercept WriteHeader calls. +// This allows deferred response header operations (e.g., proxy_hide_header, conditional +// header setting with -s/-t flags) to be applied based on the actual response status +// code and content type set by the upstream. +type snippetResponseWriter struct { + http.ResponseWriter + + headerWritten bool + onWriteHeader []func(code int, h http.Header) +} + +func (w *snippetResponseWriter) WriteHeader(code int) { + if w.headerWritten { + return + } + w.headerWritten = true + for _, fn := range w.onWriteHeader { + fn(code, w.Header()) + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *snippetResponseWriter) Write(b []byte) (int, error) { + if !w.headerWritten { + w.WriteHeader(http.StatusOK) + } + return w.ResponseWriter.Write(b) +} + +// Unwrap returns the underlying ResponseWriter, enabling http.ResponseController +// to discover the underlying writer's capabilities (Flusher, Hijacker, etc.). +func (w *snippetResponseWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + +// Flush implements http.Flusher. +func (w *snippetResponseWriter) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Hijack implements http.Hijacker. +func (w *snippetResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if h, ok := w.ResponseWriter.(http.Hijacker); ok { + return h.Hijack() + } + return nil, nil, fmt.Errorf("not a hijacker: %T", w.ResponseWriter) +} + +// writeResponse writes the final response based on the action context. +// For redirect status codes (301, 302, 303, 307, 308) with a URL, it performs an HTTP redirect. +// For other status codes, it writes the status code and optional body text. +func writeResponse(rw http.ResponseWriter, req *http.Request, ctx *actionContext) { + if ctx.statusCode == 0 { + return + } + + if ctx.redirectURL != "" { + http.Redirect(rw, req, ctx.redirectURL, ctx.statusCode) + return + } + + rw.WriteHeader(ctx.statusCode) + if ctx.body != "" { + _, _ = rw.Write([]byte(ctx.body)) + } +} + +func executePostActions(rw http.ResponseWriter, req *http.Request, ctx *actionContext) error { + for _, postActions := range ctx.nonMergeablePostActions { + for _, postAction := range postActions { + if _, err := postAction(rw, req, ctx); err != nil { + return fmt.Errorf("executing non-mergeable configuration action: %w", err) + } + } + } + + for _, postActions := range ctx.mergeablePostActions { + if _, err := postActions(rw, req, ctx); err != nil { + return fmt.Errorf("executing mergeable configuration action: %w", err) + } + } + + return nil +} diff --git a/pkg/middlewares/ingressnginx/snippet/snippet_test.go b/pkg/middlewares/ingressnginx/snippet/snippet_test.go new file mode 100644 index 0000000000..72ca66cb82 --- /dev/null +++ b/pkg/middlewares/ingressnginx/snippet/snippet_test.go @@ -0,0 +1,1499 @@ +package snippet + +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 Test_New(t *testing.T) { + testCases := []struct { + desc string + config dynamic.Snippet + expectError bool + }{ + { + desc: "fails when both snippets are empty", + config: dynamic.Snippet{}, + expectError: true, + }, + { + desc: "succeeds with valid server snippet", + config: dynamic.Snippet{ + ServerSnippet: `add_header X-Test "value";`, + }, + expectError: false, + }, + { + desc: "succeeds with valid always server snippet", + config: dynamic.Snippet{ + ServerSnippet: `add_header X-Test "value" always;`, + }, + expectError: false, + }, + { + desc: "succeeds with valid configuration snippet", + config: dynamic.Snippet{ + ConfigurationSnippet: `add_header X-Test "value";`, + }, + expectError: false, + }, + { + desc: "succeeds with both snippets", + config: dynamic.Snippet{ + ServerSnippet: `add_header X-Server "server";`, + ConfigurationSnippet: `add_header X-Config "config";`, + }, + expectError: false, + }, + { + desc: "fails with invalid server snippet syntax", + config: dynamic.Snippet{ + ServerSnippet: `add_header X-Test`, + }, + expectError: true, + }, + { + desc: "fails with invalid server snippet syntax", + config: dynamic.Snippet{ + ConfigurationSnippet: `add_header X-Test`, + }, + expectError: true, + }, + { + desc: "fails with unknown directive in server snippet", + config: dynamic.Snippet{ + ServerSnippet: `unknown_directive value;`, + }, + expectError: true, + }, + { + desc: "fails on context when proxy_set_headers in if in server snippet", + config: dynamic.Snippet{ + ServerSnippet: `if ( $request_method = "GET") { + proxy_set_header X-Test "value"; +}`, + }, + expectError: true, + }, + { + desc: "fails on context when proxy_set_headers in if in configuration snippet", + config: dynamic.Snippet{ + ConfigurationSnippet: `if ( $request_method = "GET") { + proxy_set_header X-Test "value"; +}`, + }, + expectError: true, + }, + { + desc: "fails on context when add_header in if in server snippet", + config: dynamic.Snippet{ + ServerSnippet: `if ( $request_method = "GET") { + add_header X-Test "value"; +}`, + }, + expectError: true, + }, + { + desc: "valid context when add_headers in if in configuration snippet", + config: dynamic.Snippet{ + ConfigurationSnippet: `if ( $request_method = "GET") { + add_header X-Test "value"; +}`, + }, + expectError: false, + }, + + { + desc: "succeeds with valid more_clear_headers", + config: dynamic.Snippet{ + ConfigurationSnippet: `more_clear_headers "X-Test";`, + }, + expectError: false, + }, + { + desc: "succeeds with valid more_clear_input_headers", + config: dynamic.Snippet{ + ConfigurationSnippet: `more_clear_input_headers "X-Test";`, + }, + expectError: false, + }, + { + desc: "fails with unknown directive in configuration snippet", + config: dynamic.Snippet{ + ConfigurationSnippet: `unknown_directive value;`, + }, + expectError: true, + }, + { + desc: "succeeds with valid rewrite in server snippet", + config: dynamic.Snippet{ + ServerSnippet: `rewrite ^/old$ /new last;`, + }, + expectError: false, + }, + { + desc: "succeeds with valid rewrite in configuration snippet", + config: dynamic.Snippet{ + ConfigurationSnippet: `rewrite ^/old$ /new break;`, + }, + expectError: false, + }, + { + desc: "succeeds with rewrite without flag", + config: dynamic.Snippet{ + ConfigurationSnippet: `rewrite ^/old$ /new;`, + }, + expectError: false, + }, + { + desc: "fails with rewrite with invalid regex", + config: dynamic.Snippet{ + ConfigurationSnippet: `rewrite ^/old[$ /new last;`, + }, + expectError: true, + }, + { + desc: "fails with rewrite with invalid flag", + config: dynamic.Snippet{ + ConfigurationSnippet: `rewrite ^/old$ /new invalid;`, + }, + expectError: true, + }, + { + desc: "fails with rewrite with missing parameters", + config: dynamic.Snippet{ + ConfigurationSnippet: `rewrite ^/old$;`, + }, + expectError: true, + }, + { + desc: "succeeds with allow directive", + config: dynamic.Snippet{ + ConfigurationSnippet: `allow 10.0.0.0/8;`, + }, + expectError: false, + }, + { + desc: "succeeds with deny directive", + config: dynamic.Snippet{ + ConfigurationSnippet: `deny all;`, + }, + expectError: false, + }, + { + desc: "fails with deny with invalid IP", + config: dynamic.Snippet{ + ConfigurationSnippet: `deny invalid-ip;`, + }, + expectError: true, + }, + { + desc: "succeeds with proxy_hide_header directive", + config: dynamic.Snippet{ + ServerSnippet: `proxy_hide_header X-Powered-By;`, + }, + expectError: false, + }, + { + desc: "succeeds with expires directive", + config: dynamic.Snippet{ + ConfigurationSnippet: `expires 24h;`, + }, + expectError: false, + }, + { + desc: "succeeds with expires epoch", + config: dynamic.Snippet{ + ConfigurationSnippet: `expires epoch;`, + }, + expectError: false, + }, + { + desc: "fails with expires with invalid duration", + config: dynamic.Snippet{ + ConfigurationSnippet: `expires invalid;`, + }, + expectError: true, + }, + { + desc: "fails with -s flag on more_set_input_headers", + config: dynamic.Snippet{ + ConfigurationSnippet: `more_set_input_headers -s "200" "X-Custom: value";`, + }, + expectError: true, + }, + { + desc: "fails with -s flag on more_clear_input_headers", + config: dynamic.Snippet{ + ConfigurationSnippet: `more_clear_input_headers -s "200" "X-Custom";`, + }, + expectError: true, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + _, err := New(t.Context(), next, &test.config, "test-snippet") + if test.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func Test_Directives(t *testing.T) { + testCases := []struct { + desc string + serverSnippet string + configurationSnippet string + method string + path string + remoteAddr string + requestHeaders map[string]string + expectedResponseHeaders map[string]string + unexpectedResponseHeaders []string + expectedRequestHeaders map[string]string + expectedStatusCode int + expectedBody string + expectedPath string + expectedQuery string + expectedRedirectURL string + }{ + { + desc: "add_header server snippet adds simple header", + serverSnippet: `add_header X-Custom "custom-value";`, + expectedResponseHeaders: map[string]string{"X-Custom": "custom-value"}, + }, + { + desc: "add_header server snippet adds header without quotes", + serverSnippet: `add_header X-Simple simple;`, + expectedResponseHeaders: map[string]string{"X-Simple": "simple"}, + }, + { + desc: "add_header configuration snippet adds simple header", + configurationSnippet: `add_header X-Custom "custom-value";`, + expectedResponseHeaders: map[string]string{"X-Custom": "custom-value"}, + }, + { + desc: "add_header configuration snippet adds header without quotes", + configurationSnippet: `add_header X-Simple simple;`, + expectedResponseHeaders: map[string]string{"X-Simple": "simple"}, + }, + { + desc: "add_header configuration snippet overrides server snippet", + serverSnippet: `add_header X-Server server-value;`, + configurationSnippet: `add_header X-Config config-value;`, + expectedResponseHeaders: map[string]string{ + "X-Config": "config-value", + }, + unexpectedResponseHeaders: []string{"X-Server"}, + }, + { + desc: "more_set_headers server snippet sets header", + serverSnippet: `more_set_headers "X-Custom:custom-value";`, + expectedResponseHeaders: map[string]string{"X-Custom": "custom-value"}, + }, + { + desc: "more_set_headers configuration snippet sets header", + configurationSnippet: `more_set_headers "X-Custom:custom-value";`, + expectedResponseHeaders: map[string]string{"X-Custom": "custom-value"}, + }, + { + desc: "more_set_headers both snippets set headers", + serverSnippet: `more_set_headers "X-Server:server-value";`, + configurationSnippet: `more_set_headers "X-Config:config-value";`, + expectedResponseHeaders: map[string]string{ + "X-Server": "server-value", + "X-Config": "config-value", + }, + }, + { + desc: "more_set_headers both snippets override same header", + serverSnippet: `more_set_headers "X-Header:server-value";`, + configurationSnippet: `more_set_headers "X-Header:config-value";`, + expectedResponseHeaders: map[string]string{ + "X-Header": "config-value", + }, + }, + { + desc: "more_set_headers with spaces", + configurationSnippet: `more_set_headers "X-Header: config-value ";`, + expectedResponseHeaders: map[string]string{ + "X-Header": "config-value", + }, + }, + { + desc: "more_set_headers with multiple headers in one directive", + configurationSnippet: `more_set_headers "X-First: first-value" "X-Second: second-value";`, + expectedResponseHeaders: map[string]string{ + "X-First": "first-value", + "X-Second": "second-value", + }, + }, + { + desc: "add_header with variable interpolation", + configurationSnippet: ` +add_header X-Method $request_method; +add_header X-Uri $request_uri; +`, + expectedResponseHeaders: map[string]string{ + "X-Method": "GET", + "X-Uri": "/test", + }, + }, + { + desc: "more_set_headers directive", + configurationSnippet: ` +more_set_headers "X-Custom-Header:custom-value"; +more_set_headers "X-Another:another-value"; +`, + expectedResponseHeaders: map[string]string{ + "X-Custom-Header": "custom-value", + "X-Another": "another-value", + }, + }, + { + desc: "proxy_set_header directive", + configurationSnippet: ` +proxy_set_header X-Custom-Method $request_method; +proxy_set_header X-Custom-Uri $request_uri; +`, + expectedRequestHeaders: map[string]string{ + "X-Custom-Method": "GET", + "X-Custom-Uri": "/test", + }, + }, + { + desc: "proxy_set_header with empty value removes header", + configurationSnippet: ` +proxy_set_header Accept-Encoding ""; +`, + requestHeaders: map[string]string{ + "Accept-Encoding": "gzip, deflate", + }, + unexpectedResponseHeaders: []string{ + "Accept-Encoding", + }, + }, + { + desc: "set directive creates variable", + configurationSnippet: ` +set $my_var "hello"; +add_header X-My-Var $my_var; +`, + expectedResponseHeaders: map[string]string{ + "X-My-Var": "hello", + }, + }, + { + desc: "set directive with variable interpolation", + configurationSnippet: ` +set $combined "$request_method-$request_uri"; +add_header X-Combined $combined; +`, + expectedResponseHeaders: map[string]string{ + "X-Combined": "GET-/test", + }, + }, + { + desc: "if directive with matching condition", + configurationSnippet: ` +if ($request_method = GET) { + add_header X-Is-Get "true"; +} +`, + method: http.MethodGet, + expectedResponseHeaders: map[string]string{ + "X-Is-Get": "true", + }, + }, + { + desc: "if directive with non-matching condition", + configurationSnippet: ` +if ($request_method = POST) { + add_header X-Is-Post "true"; +} +add_header X-Always "present"; +`, + method: http.MethodGet, + expectedResponseHeaders: map[string]string{ + "X-Always": "present", + }, + unexpectedResponseHeaders: []string{ + "X-Is-Post", + }, + }, + { + desc: "if directive with header check", + configurationSnippet: ` +if ($http_x_custom = "expected") { + add_header X-Matched "yes"; +} +`, + requestHeaders: map[string]string{ + "X-Custom": "expected", + }, + expectedResponseHeaders: map[string]string{ + "X-Matched": "yes", + }, + }, + { + desc: "if directive with regex match", + configurationSnippet: ` +if ($request_uri ~ "^/api") { + add_header X-Is-Api "true"; +} +`, + path: "/api/users", + expectedResponseHeaders: map[string]string{ + "X-Is-Api": "true", + }, + }, + { + desc: "if directive with case-insensitive regex match - matching", + configurationSnippet: ` +if ($http_x_custom ~* "^test") { + add_header X-Matched "yes"; +} +`, + requestHeaders: map[string]string{ + "X-Custom": "TEST-value", + }, + expectedResponseHeaders: map[string]string{ + "X-Matched": "yes", + }, + }, + { + desc: "if directive with case-insensitive regex match - not matching", + configurationSnippet: ` +if ($http_x_custom ~* "^test") { + add_header X-Matched "yes"; +} +add_header X-Always "present"; +`, + requestHeaders: map[string]string{ + "X-Custom": "other-value", + }, + expectedResponseHeaders: map[string]string{ + "X-Always": "present", + }, + unexpectedResponseHeaders: []string{ + "X-Matched", + }, + }, + { + desc: "if directive with negative case-insensitive regex match", + configurationSnippet: ` +if ($http_x_custom !~* "^admin") { + add_header X-Not-Admin "true"; +} +`, + requestHeaders: map[string]string{ + "X-Custom": "user-request", + }, + expectedResponseHeaders: map[string]string{ + "X-Not-Admin": "true", + }, + }, + { + desc: "if directive with negative case-insensitive regex match - should not match", + configurationSnippet: ` +if ($http_x_custom !~* "^admin") { + add_header X-Not-Admin "true"; +} +add_header X-Processed "yes"; +`, + requestHeaders: map[string]string{ + "X-Custom": "ADMIN-request", + }, + expectedResponseHeaders: map[string]string{ + "X-Processed": "yes", + }, + unexpectedResponseHeaders: []string{ + "X-Not-Admin", + }, + }, + { + desc: "if directive with set variable check", + configurationSnippet: ` +set $flag "enabled"; +if ($flag) { + add_header X-Flag-Set "yes"; +} +`, + expectedResponseHeaders: map[string]string{ + "X-Flag-Set": "yes", + }, + }, + { + desc: "all directives combined", + configurationSnippet: ` +set $backend_type "api"; +proxy_set_header X-Backend-Type $backend_type; +if ($request_method = GET) { + add_header X-Read-Only "true"; + more_set_headers "X-Cache-Control:public"; +} +add_header X-Powered-By "traefik"; +`, + method: http.MethodGet, + expectedResponseHeaders: map[string]string{ + "X-Read-Only": "true", + "X-Cache-Control": "public", + }, + expectedRequestHeaders: map[string]string{ + "X-Backend-Type": "api", + }, + }, + { + desc: "server and configuration snippets interaction", + serverSnippet: ` +add_header X-Server "server-value"; +set $shared "from-server"; +`, + configurationSnippet: ` +add_header X-Config "config-value"; +`, + expectedResponseHeaders: map[string]string{ + "X-Config": "config-value", + }, + unexpectedResponseHeaders: []string{ + "X-Server", + }, + }, + { + desc: "return directive with status code and text", + configurationSnippet: ` +return 403 "Forbidden"; +`, + expectedStatusCode: http.StatusForbidden, + expectedBody: "Forbidden", + }, + { + desc: "return directive with 200 status", + configurationSnippet: ` +return 200 "OK"; +`, + expectedStatusCode: http.StatusOK, + expectedBody: "OK", + }, + { + desc: "return directive inside if block - condition matches", + configurationSnippet: ` +if ($request_method = POST) { + return 405 "Method Not Allowed"; +} +add_header X-Allowed "true"; +`, + method: http.MethodPost, + expectedStatusCode: http.StatusMethodNotAllowed, + expectedBody: "Method Not Allowed", + }, + { + desc: "return directive inside if block - condition does not match", + configurationSnippet: ` +if ($request_method = POST) { + return 405 "Method Not Allowed"; +} +add_header X-Allowed "true"; +`, + method: http.MethodGet, + expectedStatusCode: http.StatusOK, + expectedResponseHeaders: map[string]string{ + "X-Allowed": "true", + }, + }, + { + desc: "return directive doesn't stop processing headers", + configurationSnippet: ` +return 204 ""; +add_header X-Should-Appear "value"; +`, + expectedStatusCode: http.StatusNoContent, + expectedBody: "", + expectedResponseHeaders: map[string]string{ + "X-Should-Appear": "value", + }, + }, + { + desc: "location without return passes through to next handler", + serverSnippet: ` +location /api { + add_header X-Location "api"; +} +`, + path: "/api/users", + expectedResponseHeaders: map[string]string{ + "X-Location": "api", + }, + }, + { + desc: "location directive with prefix match - not matching continues to next", + serverSnippet: ` +location /api { + return 200 "OK"; +} +add_header X-Always "present"; +`, + path: "/web/users", + expectedResponseHeaders: map[string]string{ + "X-Always": "present", + }, + }, + { + desc: "location directive with exact match and return", + serverSnippet: ` +location = /exact { + return 200 "exact"; +} +`, + path: "/exact", + expectedStatusCode: http.StatusOK, + expectedBody: "exact", + }, + { + desc: "location directive with exact match - not matching continues to next", + serverSnippet: ` +location = /exact { + return 200 "exact"; +} +add_header X-Always "present"; +`, + path: "/exact/more", + expectedResponseHeaders: map[string]string{ + "X-Always": "present", + }, + }, + { + desc: "location directive with regex match and return", + serverSnippet: ` +location ~ ^/api/v[0-9]+/ { + return 200 "versioned"; +} +`, + path: "/api/v2/users", + expectedStatusCode: http.StatusOK, + expectedBody: "versioned", + }, + { + desc: "location directive with regex match - not matching continues to next", + serverSnippet: ` +location ~ ^/api/v[0-9]+/ { + return 200 "versioned"; +} +add_header X-Always "present"; +`, + path: "/api/latest/users", + expectedResponseHeaders: map[string]string{ + "X-Always": "present", + }, + }, + { + desc: "location with return applies add_header always from same block", + serverSnippet: ` +location /blocked { + add_header X-Block-Header "block-value" always; + return 403 "Blocked"; +} +`, + path: "/blocked/path", + expectedStatusCode: http.StatusForbidden, + expectedBody: "Blocked", + expectedResponseHeaders: map[string]string{ + "X-Block-Header": "block-value", + }, + }, + { + desc: "add_header without always skips non-success status codes", + serverSnippet: ` +location /blocked { + add_header X-Block-Header "block-value"; + return 403 "Blocked"; +} +`, + path: "/blocked/path", + expectedStatusCode: http.StatusForbidden, + expectedBody: "Blocked", + unexpectedResponseHeaders: []string{"X-Block-Header"}, + }, + { + desc: "location with return applies more_set_headers from same block", + serverSnippet: ` +location /blocked { + more_set_headers "X-More-Header:more-value"; + return 403 "Blocked"; +} +`, + path: "/blocked/path", + expectedStatusCode: http.StatusForbidden, + expectedBody: "Blocked", + expectedResponseHeaders: map[string]string{ + "X-More-Header": "more-value", + }, + }, + { + desc: "location with return applies both add_header and more_set_headers", + serverSnippet: ` +location /api { + add_header X-Add "add-value"; + more_set_headers "X-More:more-value"; + return 200 "OK"; +} +`, + path: "/api/endpoint", + expectedStatusCode: http.StatusOK, + expectedBody: "OK", + expectedResponseHeaders: map[string]string{ + "X-Add": "add-value", + "X-More": "more-value", + }, + }, + { + desc: "add_header only applied in deepest block - location overrides root", + serverSnippet: ` +add_header X-Level "root"; +location /api { + add_header X-Level "location"; + return 200 "OK"; +} +`, + path: "/api/endpoint", + expectedStatusCode: http.StatusOK, + expectedBody: "OK", + expectedResponseHeaders: map[string]string{ + "X-Level": "location", + }, + }, + { + desc: "add_header only applied in deepest block - nested if inside location", + serverSnippet: ` +add_header X-Level "root"; +location /api { + add_header X-Level "location"; + if ($request_method = GET) { + add_header X-Level "if-block"; + return 200 "OK"; + } +} +`, + path: "/api/endpoint", + method: http.MethodGet, + expectedStatusCode: http.StatusOK, + expectedBody: "OK", + expectedResponseHeaders: map[string]string{ + "X-Level": "if-block", + }, + }, + { + desc: "add_header from location when if condition not matched", + serverSnippet: ` +add_header X-Level "root"; +location /api { + add_header X-Level "location"; + if ($request_method = POST) { + add_header X-Level "if-block"; + return 200 "POST"; + } + return 200 "OTHER"; +} +`, + path: "/api/endpoint", + method: http.MethodGet, + expectedStatusCode: http.StatusOK, + expectedBody: "OTHER", + expectedResponseHeaders: map[string]string{ + "X-Level": "location", + }, + }, + { + desc: "root add_header applied when location not matched", + serverSnippet: ` +add_header X-Level "root"; +location /api { + add_header X-Level "location"; + return 200 "API"; +} +`, + path: "/web/endpoint", + expectedResponseHeaders: map[string]string{ + "X-Level": "root", + }, + }, + { + desc: "more_set_input_headers sets request header", + configurationSnippet: ` +more_set_input_headers "X-Custom-Input:input-value"; +`, + expectedRequestHeaders: map[string]string{ + "X-Custom-Input": "input-value", + }, + }, + { + desc: "more_set_input_headers with variable interpolation", + configurationSnippet: ` +more_set_input_headers "X-Method-Input:$request_method"; +`, + expectedRequestHeaders: map[string]string{ + "X-Method-Input": "GET", + }, + }, + { + desc: "more_set_headers with multiple headers per directive", + configurationSnippet: ` +more_set_headers "X-Foo: bar" "X-Baz: qux"; +`, + expectedResponseHeaders: map[string]string{ + "X-Foo": "bar", + "X-Baz": "qux", + }, + }, + { + desc: "more_set_headers clearing header with colon", + serverSnippet: ` +more_set_headers "X-Foo: server-value"; +`, + configurationSnippet: ` +more_set_headers "X-Foo:"; +`, + unexpectedResponseHeaders: []string{ + "X-Foo", + }, + }, + { + desc: "more_set_headers clearing header without colon", + serverSnippet: ` +more_set_headers "X-Foo: server-value"; +`, + configurationSnippet: ` +more_set_headers "X-Foo"; +`, + unexpectedResponseHeaders: []string{ + "X-Foo", + }, + }, + { + desc: "more_set_input_headers with multiple headers per directive", + configurationSnippet: ` +more_set_input_headers "X-Foo: bar" "X-Baz: qux"; +`, + expectedRequestHeaders: map[string]string{ + "X-Foo": "bar", + "X-Baz": "qux", + }, + }, + { + desc: "more_set_input_headers clearing request header", + configurationSnippet: ` +more_set_input_headers "X-Foo"; +`, + requestHeaders: map[string]string{ + "X-Foo": "original-value", + }, + unexpectedResponseHeaders: []string{ + "X-Foo", + }, + }, + { + desc: "more_clear_headers clears a single response header", + configurationSnippet: ` +more_set_headers "X-Remove-Me: some-value"; +more_clear_headers "X-Remove-Me"; +`, + unexpectedResponseHeaders: []string{ + "X-Remove-Me", + }, + }, + { + desc: "more_clear_headers clears multiple response headers", + configurationSnippet: ` +more_set_headers "X-Foo: foo-val"; +more_set_headers "X-Bar: bar-val"; +more_set_headers "X-Keep: keep-val"; +more_clear_headers Foo Bar; +`, + expectedResponseHeaders: map[string]string{ + "X-Foo": "foo-val", + "X-Bar": "bar-val", + "X-Keep": "keep-val", + }, + }, + { + desc: "more_clear_headers clears multiple headers by exact name", + configurationSnippet: ` +more_set_headers "X-Foo: foo-val"; +more_set_headers "X-Bar: bar-val"; +more_set_headers "X-Keep: keep-val"; +more_clear_headers "X-Foo" "X-Bar"; +`, + expectedResponseHeaders: map[string]string{ + "X-Foo": "", + "X-Bar": "", + "X-Keep": "keep-val", + }, + }, + { + desc: "more_clear_headers with wildcard pattern", + configurationSnippet: ` +more_set_headers "X-Hidden-One: val1"; +more_set_headers "X-Hidden-Two: val2"; +more_set_headers "X-Visible: visible"; +more_clear_headers "X-Hidden-*"; +`, + expectedResponseHeaders: map[string]string{ + "X-Visible": "visible", + }, + unexpectedResponseHeaders: []string{ + "X-Hidden-One", + "X-Hidden-Two", + }, + }, + { + desc: "more_clear_input_headers removes a request header", + configurationSnippet: ` +more_clear_input_headers "X-Secret"; +`, + requestHeaders: map[string]string{ + "X-Secret": "secret-value", + }, + unexpectedResponseHeaders: []string{ + "X-Secret", + }, + }, + { + desc: "more_clear_input_headers with wildcard removes matching request headers", + configurationSnippet: ` +more_clear_input_headers "X-Custom-*"; +`, + requestHeaders: map[string]string{ + "X-Custom-One": "val1", + "X-Custom-Two": "val2", + "X-Other": "other", + }, + expectedRequestHeaders: map[string]string{ + "X-Other": "other", + }, + unexpectedResponseHeaders: []string{ + "X-Custom-One", + "X-Custom-Two", + }, + }, + { + desc: "more_clear_headers in configuration-snippet clears server-snippet header", + serverSnippet: ` +more_set_headers "X-Server-Header: server-value"; +`, + configurationSnippet: ` +more_clear_headers "X-Server-Header"; +`, + unexpectedResponseHeaders: []string{ + "X-Server-Header", + }, + }, + { + desc: "rewrite with capture groups and last flag", + serverSnippet: ` +rewrite ^/old/(.*)$ /new/$1 last; +`, + path: "/old/page", + expectedPath: "/new/page", + }, + { + desc: "rewrite with permanent redirect", + serverSnippet: ` +rewrite ^ https://example.com$request_uri? permanent; +`, + path: "/some/path", + expectedStatusCode: http.StatusMovedPermanently, + expectedRedirectURL: "https://example.com/some/path", + }, + { + desc: "rewrite with break flag", + configurationSnippet: ` +rewrite ^/api/v1/(.*)$ /api/v2/$1 break; +`, + path: "/api/v1/users", + expectedPath: "/api/v2/users", + }, + { + desc: "rewrite with redirect flag", + configurationSnippet: ` +rewrite ^/old$ /new redirect; +`, + path: "/old", + expectedStatusCode: http.StatusFound, + expectedRedirectURL: "/new", + }, + { + desc: "rewrite with no match passes through", + configurationSnippet: ` +rewrite ^/nomatch /other last; +`, + path: "/test", + expectedPath: "/test", + }, + { + desc: "rewrite preserves query string", + configurationSnippet: ` +rewrite ^/search$ /find last; +`, + path: "/search?q=test", + expectedPath: "/find", + expectedQuery: "q=test", + }, + { + desc: "rewrite with ? suffix suppresses query string", + configurationSnippet: ` +rewrite ^/search$ /find? last; +`, + path: "/search?q=test", + expectedPath: "/find", + expectedQuery: "", + }, + { + desc: "rewrite in configuration-snippet (location context)", + configurationSnippet: ` +rewrite ^/old/(.*)$ /new/$1 last; +`, + path: "/old/resource", + expectedPath: "/new/resource", + }, + { + desc: "rewrite in if block", + configurationSnippet: ` +if ($request_method = GET) { + rewrite ^/old/(.*)$ /new/$1 last; +} +`, + method: http.MethodGet, + path: "/old/page", + expectedPath: "/new/page", + }, + { + desc: "rewrite with multiple capture groups", + serverSnippet: ` +rewrite ^/download/(.*)/media/(.*)\..*$ /download/$1/mp3/$2.mp3 last; +`, + path: "/download/music/media/song.flac", + expectedPath: "/download/music/mp3/song.mp3", + }, + { + desc: "rewrite with no flag continues processing", + configurationSnippet: ` +rewrite ^/a$ /b; +rewrite ^/b$ /c last; +`, + path: "/a", + expectedPath: "/c", + }, + { + desc: "rewrite with URL-based redirect (http://)", + configurationSnippet: ` +rewrite ^/old$ http://other.example.com/new last; +`, + path: "/old", + expectedStatusCode: http.StatusFound, + expectedRedirectURL: "http://other.example.com/new", + }, + // --- add_header always tests --- + { + desc: "add_header with always applies to 200 status", + configurationSnippet: ` +add_header X-Custom "always-value" always; +`, + expectedResponseHeaders: map[string]string{ + "X-Custom": "always-value", + }, + }, + { + desc: "add_header without always applies to 200 status", + configurationSnippet: ` +add_header X-Custom "no-always-value"; +`, + expectedResponseHeaders: map[string]string{ + "X-Custom": "no-always-value", + }, + }, + { + desc: "add_header without always skips 404 status", + serverSnippet: ` +location /missing { + add_header X-Custom "no-always-value"; + return 404 "Not Found"; +} +`, + path: "/missing", + expectedStatusCode: http.StatusNotFound, + unexpectedResponseHeaders: []string{"X-Custom"}, + }, + { + desc: "add_header with always applies to 404 status", + serverSnippet: ` +location /missing { + add_header X-Custom "always-value" always; + return 404 "Not Found"; +} +`, + path: "/missing", + expectedStatusCode: http.StatusNotFound, + expectedResponseHeaders: map[string]string{ + "X-Custom": "always-value", + }, + }, + // --- deny/allow tests --- + { + desc: "deny all blocks request", + configurationSnippet: ` +deny all; +`, + remoteAddr: "192.168.1.1:12345", + expectedStatusCode: http.StatusForbidden, + expectedBody: "403 Forbidden", + }, + { + desc: "allow all permits request", + configurationSnippet: ` +allow all; +`, + remoteAddr: "192.168.1.1:12345", + }, + { + desc: "deny specific IP blocks matching request", + configurationSnippet: ` +deny 10.0.0.1; +`, + remoteAddr: "10.0.0.1:12345", + expectedStatusCode: http.StatusForbidden, + expectedBody: "403 Forbidden", + }, + { + desc: "deny specific IP allows non-matching request", + configurationSnippet: ` +deny 10.0.0.1; +`, + remoteAddr: "10.0.0.2:12345", + }, + { + desc: "deny CIDR blocks matching request", + configurationSnippet: ` +deny 10.0.0.0/24; +`, + remoteAddr: "10.0.0.50:12345", + expectedStatusCode: http.StatusForbidden, + expectedBody: "403 Forbidden", + }, + { + desc: "deny CIDR allows non-matching request", + configurationSnippet: ` +deny 10.0.0.0/24; +`, + remoteAddr: "10.0.1.1:12345", + }, + { + desc: "allow then deny all permits allowed IP", + configurationSnippet: ` +allow 192.168.1.0/24; +deny all; +`, + remoteAddr: "192.168.1.50:12345", + }, + { + desc: "allow then deny all blocks non-allowed IP", + configurationSnippet: ` +allow 192.168.1.0/24; +deny all; +`, + remoteAddr: "10.0.0.1:12345", + expectedStatusCode: http.StatusForbidden, + expectedBody: "403 Forbidden", + }, + // --- proxy_hide_header tests --- + { + desc: "proxy_hide_header removes response header", + configurationSnippet: ` +proxy_hide_header X-Powered-By; +`, + unexpectedResponseHeaders: []string{"X-Powered-By"}, + }, + // --- expires tests --- + { + desc: "expires epoch sets no-cache", + configurationSnippet: ` +expires epoch; +`, + expectedResponseHeaders: map[string]string{ + "Expires": "Thu, 01 Jan 1970 00:00:01 GMT", + "Cache-Control": "no-cache", + }, + }, + { + desc: "expires max sets far-future cache", + configurationSnippet: ` +expires max; +`, + expectedResponseHeaders: map[string]string{ + "Expires": "Thu, 31 Dec 2037 23:55:55 GMT", + "Cache-Control": "max-age=315360000", + }, + }, + { + desc: "expires off does not set cache headers", + configurationSnippet: ` +expires off; +`, + unexpectedResponseHeaders: []string{"Expires", "Cache-Control"}, + }, + // --- more_set_headers with flags --- + { + desc: "more_set_headers with -a append flag", + configurationSnippet: ` +more_set_headers "X-Custom: first"; +more_set_headers -a "X-Custom: second"; +`, + expectedResponseHeaders: map[string]string{ + "X-Custom": "first", + }, + }, + { + desc: "more_set_headers clearing header with empty value", + configurationSnippet: ` +more_set_headers "X-Remove:"; +`, + unexpectedResponseHeaders: []string{"X-Remove"}, + }, + { + desc: "more_set_headers clearing header without colon", + configurationSnippet: ` +more_set_headers "X-Remove"; +`, + unexpectedResponseHeaders: []string{"X-Remove"}, + }, + // --- more_set_input_headers with -r restrict flag --- + { + desc: "more_set_input_headers with -r only sets existing header", + configurationSnippet: ` +more_set_input_headers -r "X-Existing: new-value"; +`, + requestHeaders: map[string]string{ + "X-Existing": "old-value", + }, + expectedRequestHeaders: map[string]string{ + "X-Existing": "new-value", + }, + }, + { + desc: "more_set_input_headers with -r skips non-existing header", + configurationSnippet: ` +more_set_input_headers -r "X-Missing: new-value"; +`, + unexpectedResponseHeaders: []string{ + "X-Missing", + }, + }, + // --- more_set_headers with multiple headers per directive --- + { + desc: "more_set_headers with multiple headers in single directive", + configurationSnippet: ` +more_set_headers "X-One: val1" "X-Two: val2"; +`, + expectedResponseHeaders: map[string]string{ + "X-One": "val1", + "X-Two": "val2", + }, + }, + // --- more_clear_input_headers --- + { + desc: "more_clear_input_headers removes request header", + configurationSnippet: ` +more_clear_input_headers "X-Remove"; +`, + requestHeaders: map[string]string{ + "X-Remove": "should-be-removed", + }, + unexpectedResponseHeaders: []string{ + "X-Remove", + }, + }, + // --- rewrite last/break forwards to upstream --- + { + desc: "rewrite break in server snippet skips configuration snippet", + serverSnippet: ` +rewrite ^/old/(.*)$ /new/$1 break; +`, + configurationSnippet: ` +add_header X-Config "config-value" always; +`, + path: "/old/page", + expectedPath: "/new/page", + unexpectedResponseHeaders: []string{"X-Config"}, + }, + { + desc: "rewrite last in server snippet allows configuration snippet to run", + serverSnippet: ` +rewrite ^/old/(.*)$ /new/$1 last; +`, + configurationSnippet: ` +add_header X-Config "config-value" always; +`, + path: "/old/page", + expectedPath: "/new/page", + expectedResponseHeaders: map[string]string{ + "X-Config": "config-value", + }, + }, + // --- if single variable check with built-in variables --- + { + desc: "if single variable check with built-in variable", + configurationSnippet: ` +if ($request_method) { + add_header X-Has-Method "yes"; +} +`, + expectedResponseHeaders: map[string]string{ + "X-Has-Method": "yes", + }, + }, + // --- if regex capture groups --- + { + desc: "if regex capture groups stored as $1", + configurationSnippet: ` +if ($request_uri ~ "^/api/(.*)") { + add_header X-Captured "$1"; +} +`, + path: "/api/users", + expectedResponseHeaders: map[string]string{ + "X-Captured": "users", + }, + }, + // --- location ~* case-insensitive regex --- + { + desc: "location with case-insensitive regex matches", + serverSnippet: ` +location ~* \.css$ { + add_header X-Type "css" always; + return 200 "CSS"; +} +`, + path: "/style/main.CSS", + expectedStatusCode: http.StatusOK, + expectedBody: "CSS", + expectedResponseHeaders: map[string]string{ + "X-Type": "css", + }, + }, + { + desc: "location with case-insensitive regex does not match", + serverSnippet: ` +location ~* \.css$ { + return 200 "CSS"; +} +add_header X-Fallback "yes"; +`, + path: "/style/main.js", + expectedResponseHeaders: map[string]string{ + "X-Fallback": "yes", + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + capturedRequestHeaders := make(map[string]string) + var capturedPath, capturedQuery string + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + capturedPath = r.URL.Path + capturedQuery = r.URL.RawQuery + for header := range test.expectedRequestHeaders { + capturedRequestHeaders[header] = r.Header.Get(header) + } + w.WriteHeader(http.StatusOK) + }) + + config := &dynamic.Snippet{ + ServerSnippet: test.serverSnippet, + ConfigurationSnippet: test.configurationSnippet, + } + + handler, err := New(t.Context(), next, config, "test-snippet") + require.NoError(t, err) + + method := test.method + if method == "" { + method = http.MethodGet + } + path := test.path + if path == "" { + path = "/test" + } + + req := httptest.NewRequest(method, "http://example.com"+path, nil) + if test.remoteAddr != "" { + req.RemoteAddr = test.remoteAddr + } + for k, v := range test.requestHeaders { + req.Header.Set(k, v) + } + rw := httptest.NewRecorder() + + handler.ServeHTTP(rw, req) + + expectedStatusCode := test.expectedStatusCode + if expectedStatusCode == 0 { + expectedStatusCode = http.StatusOK + } + assert.Equal(t, expectedStatusCode, rw.Code) + + if test.expectedBody != "" { + assert.Equal(t, test.expectedBody, rw.Body.String()) + } + + // If a return directive was used, next should not be called + if test.expectedStatusCode != 0 && test.expectedStatusCode != http.StatusOK { + assert.False(t, nextCalled, "next handler should not be called when return directive is used") + } + + for header, expectedValue := range test.expectedResponseHeaders { + assert.Equal(t, expectedValue, rw.Header().Get(header), "response header %s", header) + } + + for _, header := range test.unexpectedResponseHeaders { + assert.Empty(t, rw.Header().Get(header), "response header %s should not be set", header) + } + + for header, expectedValue := range test.expectedRequestHeaders { + assert.Equal(t, expectedValue, capturedRequestHeaders[header], "request header %s", header) + } + + if test.expectedPath != "" && nextCalled { + assert.Equal(t, test.expectedPath, capturedPath, "rewritten path") + } + + if test.expectedQuery != "" && nextCalled { + assert.Equal(t, test.expectedQuery, capturedQuery, "rewritten query") + } else if test.expectedQuery == "" && test.expectedPath != "" && nextCalled { + assert.Empty(t, capturedQuery, "query string should be empty") + } + + if test.expectedRedirectURL != "" { + assert.Contains(t, rw.Header().Get("Location"), test.expectedRedirectURL, "redirect URL") + } + }) + } +} diff --git a/pkg/provider/kubernetes/ingress-nginx/annotations.go b/pkg/provider/kubernetes/ingress-nginx/annotations.go index 833ecb08d4..09b89b7f7e 100644 --- a/pkg/provider/kubernetes/ingress-nginx/annotations.go +++ b/pkg/provider/kubernetes/ingress-nginx/annotations.go @@ -97,6 +97,9 @@ type ingressConfig struct { ProxyBuffersNumber *int `annotation:"nginx.ingress.kubernetes.io/proxy-buffers-number"` // ProxyMaxTempFileSize sets the maximum size of a temporary file used to buffer responses. ProxyMaxTempFileSize *string `annotation:"nginx.ingress.kubernetes.io/proxy-max-temp-file-size"` + + ConfigurationSnippet *string `annotation:"nginx.ingress.kubernetes.io/configuration-snippet"` + ServerSnippet *string `annotation:"nginx.ingress.kubernetes.io/server-snippet"` } // parseIngressConfig parses the annotations from an Ingress object into an ingressConfig struct. diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-both-snippets.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-both-snippets.yml new file mode 100644 index 0000000000..0a87bc0665 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-both-snippets.yml @@ -0,0 +1,25 @@ +--- +kind: Ingress +apiVersion: networking.k8s.io/v1 +metadata: + name: ingress-with-both-snippets + namespace: default + annotations: + nginx.ingress.kubernetes.io/server-snippet: | + add_header X-Server-Snippet "server-value"; + nginx.ingress.kubernetes.io/configuration-snippet: | + add_header X-Configuration-Snippet "configuration-value"; + +spec: + ingressClassName: nginx + rules: + - host: snippet.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 \ No newline at end of file diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-configuration-snippet.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-configuration-snippet.yml new file mode 100644 index 0000000000..1d9b06705b --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-configuration-snippet.yml @@ -0,0 +1,23 @@ +--- +kind: Ingress +apiVersion: networking.k8s.io/v1 +metadata: + name: ingress-with-configuration-snippet + namespace: default + annotations: + nginx.ingress.kubernetes.io/configuration-snippet: | + add_header X-Configuration-Snippet "configuration-value"; + +spec: + ingressClassName: nginx + rules: + - host: snippet.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 \ No newline at end of file diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-server-snippet.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-server-snippet.yml new file mode 100644 index 0000000000..7acfb4dd0e --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-server-snippet.yml @@ -0,0 +1,23 @@ +--- +kind: Ingress +apiVersion: networking.k8s.io/v1 +metadata: + name: ingress-with-server-snippet + namespace: default + annotations: + nginx.ingress.kubernetes.io/server-snippet: | + add_header X-Server-Snippet "server-value"; + +spec: + ingressClassName: nginx + rules: + - host: snippet.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 \ No newline at end of file diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go index bc5d221ebe..13701633c1 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go @@ -88,6 +88,12 @@ type certBlocks struct { Certificate *tls.Certificate } +type ingress struct { + *netv1.Ingress + + IngressConfig ingressConfig +} + // Provider holds configurations of the provider. type Provider struct { Endpoint string `description:"Kubernetes server endpoint (required for external cluster client)." json:"endpoint,omitempty" toml:"endpoint,omitempty" yaml:"endpoint,omitempty"` @@ -129,6 +135,8 @@ type Provider struct { AllowCrossNamespaceResources bool `description:"Allow Ingress to reference resources (e.g. ConfigMaps, Secrets) in different namespaces." json:"allowCrossNamespaceResources,omitempty" toml:"allowCrossNamespaceResources,omitempty" yaml:"allowCrossNamespaceResources,omitempty" export:"true"` GlobalAllowedResponseHeaders []string `description:"List of allowed response headers inside the custom headers annotations." json:"globalAllowedResponseHeaders,omitempty" toml:"globalAllowedResponseHeaders,omitempty" yaml:"globalAllowedResponseHeaders,omitempty" export:"true"` + AllowSnippetAnnotations bool `description:"Enables to parse and add -snippet annotations/directives." json:"allowSnippetAnnotations,omitempty" toml:"allowSnippetAnnotations,omitempty" yaml:"allowSnippetAnnotations,omitempty" export:"true"` + // NonTLSEntryPoints contains the names of entrypoints that are configured without TLS. NonTLSEntryPoints []string `json:"-" toml:"-" yaml:"-" label:"-" file:"-"` @@ -338,17 +346,31 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration } ingressClasses = filterIngressClass(ics, p.IngressClassByName, p.IngressClass, p.ControllerClass) - ingresses := p.k8sClient.ListIngresses() + var ingresses []ingress hosts := make(map[string]bool) - for _, ing := range ingresses { + serverSnippets := make(map[string]string) + for _, ing := range p.k8sClient.ListIngresses() { if !p.shouldProcessIngress(ing, ingressClasses) { continue } + logger := log.Ctx(ctx).With().Str("ingress", ing.Name).Str("namespace", ing.Namespace).Logger() + ingressConfig := parseIngressConfig(ing) + for _, rule := range ing.Spec.Rules { - hosts[strings.ToLower(rule.Host)] = true + hosts[rule.Host] = true + + if srvSnippet := ptr.Deref(ingressConfig.ServerSnippet, ""); srvSnippet != "" { + if serverSnippets[rule.Host] != "" { + logger.Debug().Msgf("Ignoring Server snippet because it is already defined for Host: %s", rule.Host) + } else { + serverSnippets[rule.Host] = srvSnippet + } + } } + + ingresses = append(ingresses, ingress{Ingress: ing, IngressConfig: ingressConfig}) } uniqCerts := make(map[string]*tls.CertAndStores) @@ -357,31 +379,25 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration logger := log.Ctx(ctx).With().Str("ingress", ingress.Name).Str("namespace", ingress.Namespace).Logger() ctxIngress := logger.WithContext(ctx) - if !p.shouldProcessIngress(ingress, ingressClasses) { - continue - } - - if err := p.updateIngressStatus(ingress); err != nil { + if err := p.updateIngressStatus(ingress.Ingress); err != nil { logger.Error().Err(err).Msg("Error while updating ingress status") } var hasTLS bool if len(ingress.Spec.TLS) > 0 { hasTLS = true - if err := p.loadCertificates(ctxIngress, ingress, uniqCerts); err != nil { + if err := p.loadCertificates(ctxIngress, ingress.Ingress, uniqCerts); err != nil { logger.Error().Err(err).Msg("Error configuring TLS") continue } } - ingressConfig := parseIngressConfig(ingress) - var clientAuthTLSOptionName string - if ingressConfig.AuthTLSSecret != nil { - tlsOptName := provider.Normalize(ingress.Namespace + "-" + ingress.Name + "-" + *ingressConfig.AuthTLSSecret) + if ingress.IngressConfig.AuthTLSSecret != nil { + tlsOptName := provider.Normalize(ingress.Namespace + "-" + ingress.Name + "-" + *ingress.IngressConfig.AuthTLSSecret) if _, exists := tlsOptions[tlsOptName]; !exists { - tlsOpt, err := p.buildClientAuthTLSOption(ingress.Namespace, ingressConfig) + tlsOpt, err := p.buildClientAuthTLSOption(ingress.Namespace, ingress.IngressConfig) if err != nil { logger.Error().Err(err).Msg("Error configuring client auth TLS") continue @@ -393,7 +409,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration clientAuthTLSOptionName = tlsOptName } - namedServersTransport, err := p.buildServersTransport(ctxIngress, ingress.Namespace, ingress.Name, ingressConfig) + namedServersTransport, err := p.buildServersTransport(ctxIngress, ingress.Namespace, ingress.Name, ingress.IngressConfig) if err != nil { logger.Error().Err(err).Msg("Ignoring Ingress cannot create proxy SSL configuration") continue @@ -402,7 +418,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration var defaultBackendService *dynamic.Service if ingress.Spec.DefaultBackend != nil && ingress.Spec.DefaultBackend.Service != nil { var err error - defaultBackendService, err = p.buildService(ingress.Namespace, *ingress.Spec.DefaultBackend, ingressConfig) + defaultBackendService, err = p.buildService(ingress.Namespace, *ingress.Spec.DefaultBackend, ingress.IngressConfig) if err != nil { logger.Error(). Str("serviceName", ingress.Spec.DefaultBackend.Service.Name). @@ -421,7 +437,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration Service: defaultBackendName, } - if err := p.applyMiddlewares(ingress.Namespace, ingress.Name, defaultBackendName, "", "", ingress.Spec.DefaultBackend, hosts, ingressConfig, hasTLS, rt, conf); err != nil { + if err := p.applyMiddlewares(ingress.Namespace, ingress.Name, defaultBackendName, "", "", ingress.Spec.DefaultBackend, hosts, ingress.IngressConfig, hasTLS, rt, conf, ""); err != nil { logger.Error().Err(err).Msg("Error applying middlewares") } @@ -439,7 +455,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration rtTLS.TLS.Options = clientAuthTLSOptionName } - if err := p.applyMiddlewares(ingress.Namespace, ingress.Name, defaultBackendTLSName, "", "", ingress.Spec.DefaultBackend, hosts, ingressConfig, false, rtTLS, conf); err != nil { + if err := p.applyMiddlewares(ingress.Namespace, ingress.Name, defaultBackendTLSName, "", "", ingress.Spec.DefaultBackend, hosts, ingress.IngressConfig, false, rtTLS, conf, ""); err != nil { logger.Error().Err(err).Msg("Error applying middlewares") } @@ -453,7 +469,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration } for ri, rule := range ingress.Spec.Rules { - if ptr.Deref(ingressConfig.SSLPassthrough, false) { + if ptr.Deref(ingress.IngressConfig.SSLPassthrough, false) { if rule.Host == "" { logger.Error().Err(err).Msg("Cannot process ssl-passthrough for rule without host") continue @@ -477,7 +493,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration continue } - service, err := p.buildPassthroughService(ingress.Namespace, *backend, ingressConfig) + service, err := p.buildPassthroughService(ingress.Namespace, *backend, ingress.IngressConfig) if err != nil { logger.Error().Err(err).Msgf("Cannot create passthrough service for %s", backend.Service.Name) continue @@ -516,7 +532,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration Service: key, } - if err := p.applyMiddlewares(ingress.Namespace, ingress.Name, key, "", "", ingress.Spec.DefaultBackend, hosts, ingressConfig, hasTLS, rt, conf); err != nil { + if err := p.applyMiddlewares(ingress.Namespace, ingress.Name, key, "", "", ingress.Spec.DefaultBackend, hosts, ingress.IngressConfig, hasTLS, rt, conf, serverSnippets[rule.Host]); err != nil { logger.Error().Err(err).Msg("Error applying middlewares") } @@ -533,7 +549,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration rtTLS.TLS.Options = clientAuthTLSOptionName } - if err := p.applyMiddlewares(ingress.Namespace, ingress.Name, key+"-tls", "", "", ingress.Spec.DefaultBackend, hosts, ingressConfig, false, rtTLS, conf); err != nil { + if err := p.applyMiddlewares(ingress.Namespace, ingress.Name, key+"-tls", "", "", ingress.Spec.DefaultBackend, hosts, ingress.IngressConfig, false, rtTLS, conf, serverSnippets[rule.Host]); err != nil { logger.Error().Err(err).Msg("Error applying middlewares") } @@ -568,7 +584,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration // TODO: if no service, do not add middlewares and 503. serviceName := provider.Normalize(ingress.Namespace + "-" + ingress.Name + "-" + pa.Backend.Service.Name + "-" + portString) - service, err := p.buildService(ingress.Namespace, pa.Backend, ingressConfig) + service, err := p.buildService(ingress.Namespace, pa.Backend, ingress.IngressConfig) if err != nil { logger.Error(). Str("serviceName", pa.Backend.Service.Name). @@ -579,7 +595,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration } rt := &dynamic.Router{ - Rule: buildRule(ctxIngress, rule.Host, pa, ingressConfig, hosts), + Rule: buildRule(ctxIngress, rule.Host, pa, ingress.IngressConfig, hosts), // "default" stands for the default rule syntax in Traefik v3, i.e. the v3 syntax. RuleSyntax: "default", Service: serviceName, @@ -602,7 +618,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration conf.HTTP.ServersTransports[namedServersTransport.Name] = namedServersTransport.ServersTransport } - if err := p.applyMiddlewares(ingress.Namespace, ingress.Name, routerKey, pa.Path, rule.Host, &pa.Backend, hosts, ingressConfig, hasTLS, rt, conf); err != nil { + if err := p.applyMiddlewares(ingress.Namespace, ingress.Name, routerKey, pa.Path, rule.Host, &pa.Backend, hosts, ingress.IngressConfig, hasTLS, rt, conf, serverSnippets[rule.Host]); err != nil { logger.Error().Err(err).Msg("Error applying middlewares") } } @@ -968,12 +984,11 @@ func (p *Provider) loadCertificates(ctx context.Context, ingress *netv1.Ingress, return nil } -func (p *Provider) applyMiddlewares(namespace, ingressName, routerKey, rulePath, ruleHost string, backend *netv1.IngressBackend, hosts map[string]bool, ingressConfig ingressConfig, hasTLS bool, rt *dynamic.Router, conf *dynamic.Configuration) error { +func (p *Provider) applyMiddlewares(namespace, ingressName, routerKey, rulePath, ruleHost string, backend *netv1.IngressBackend, hosts map[string]bool, ingressConfig ingressConfig, hasTLS bool, rt *dynamic.Router, conf *dynamic.Configuration, serverSnippet string) error { err := p.applyCustomHTTPErrors(namespace, ingressName, routerKey, backend, ingressConfig, rt, conf) if err != nil { return err } - applyAppRootConfiguration(routerKey, ingressConfig, rt, conf) applyFromToWwwRedirect(hosts, ruleHost, routerKey, ingressConfig, rt, conf) applyRedirect(routerKey, ingressConfig, rt, conf) @@ -1010,11 +1025,38 @@ func (p *Provider) applyMiddlewares(namespace, ingressName, routerKey, rulePath, return fmt.Errorf("applying custom headers: %w", err) } + if err := p.applySnippets(routerKey, serverSnippet, ingressConfig, rt, conf); err != nil { + return fmt.Errorf("applying snippets: %w", err) + } + p.applyRetry(routerKey, ingressConfig, rt, conf) return nil } +func (p *Provider) applySnippets(routerName, serverSnippet string, ingressConfig ingressConfig, rt *dynamic.Router, conf *dynamic.Configuration) error { + configurationSnippet := ptr.Deref(ingressConfig.ConfigurationSnippet, "") + if serverSnippet == "" && configurationSnippet == "" { + return nil + } + + if !p.AllowSnippetAnnotations { + return errors.New("snippet annotations are not allowed") + } + + snippetMiddlewareName := routerName + "-snippet" + conf.HTTP.Middlewares[snippetMiddlewareName] = &dynamic.Middleware{ + Snippet: &dynamic.Snippet{ + ServerSnippet: serverSnippet, + ConfigurationSnippet: configurationSnippet, + }, + } + + rt.Middlewares = append(rt.Middlewares, snippetMiddlewareName) + + return nil +} + func (p *Provider) applyCustomHTTPErrors(namespace, ingressName, routerName string, targetedService *netv1.IngressBackend, config ingressConfig, rt *dynamic.Router, conf *dynamic.Configuration) error { customHTTPErrors := ptr.Deref(config.CustomHTTPErrors, p.CustomHTTPErrors) if len(customHTTPErrors) == 0 { @@ -1210,7 +1252,6 @@ func applyFromToWwwRedirect(hosts map[string]bool, ruleHost, routerName string, return } - ruleHost = strings.ToLower(ruleHost) wwwType := strings.HasPrefix(ruleHost, "www.") wildcardType := strings.HasPrefix(ruleHost, "*.") bypass := wwwType && hosts[strings.TrimPrefix(ruleHost, "www.")] || !wwwType && hosts["www."+ruleHost] || wildcardType diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go index a004136f2d..34b4685a2a 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go @@ -30,6 +30,7 @@ func TestLoadIngresses(t *testing.T) { defaultBackendServiceNamespace string allowCrossNamespaceResources bool globalAllowedResponseHeaders []string + allowSnippetAnnotations bool paths []string expected *dynamic.Configuration }{ @@ -1301,10 +1302,13 @@ func TestLoadIngresses(t *testing.T) { HTTP: &dynamic.HTTPConfiguration{ Routers: map[string]*dynamic.Router{ "default-ingress-with-rewrite-target-no-regex-rule-0-path-0": { - Rule: "Host(`rewrite-target-no-regex.localhost`) && Path(`/original`)", - RuleSyntax: "default", - Service: "default-ingress-with-rewrite-target-no-regex-whoami-80", - Middlewares: []string{"default-ingress-with-rewrite-target-no-regex-rule-0-path-0-rewrite-target", "default-ingress-with-rewrite-target-no-regex-rule-0-path-0-retry"}, + Rule: "Host(`rewrite-target-no-regex.localhost`) && Path(`/original`)", + RuleSyntax: "default", + Service: "default-ingress-with-rewrite-target-no-regex-whoami-80", + Middlewares: []string{ + "default-ingress-with-rewrite-target-no-regex-rule-0-path-0-rewrite-target", + "default-ingress-with-rewrite-target-no-regex-rule-0-path-0-retry", + }, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -1314,7 +1318,9 @@ func TestLoadIngresses(t *testing.T) { }, }, "default-ingress-with-rewrite-target-no-regex-rule-0-path-0-retry": { - Retry: &dynamic.Retry{Attempts: 3}, + Retry: &dynamic.Retry{ + Attempts: 3, + }, }, }, Services: map[string]*dynamic.Service{ @@ -3205,10 +3211,13 @@ func TestLoadIngresses(t *testing.T) { HTTP: &dynamic.HTTPConfiguration{ Routers: map[string]*dynamic.Router{ "default-ingress-with-custom-http-errors-and-default-backend-rule-0-path-0": { - Rule: "Host(`whoami.localhost`) && Path(`/`)", - RuleSyntax: "default", - Service: "default-ingress-with-custom-http-errors-and-default-backend-whoami-80", - Middlewares: []string{"default-ingress-with-custom-http-errors-and-default-backend-rule-0-path-0-custom-http-errors", "default-ingress-with-custom-http-errors-and-default-backend-rule-0-path-0-retry"}, + Rule: "Host(`whoami.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-custom-http-errors-and-default-backend-whoami-80", + Middlewares: []string{ + "default-ingress-with-custom-http-errors-and-default-backend-rule-0-path-0-custom-http-errors", + "default-ingress-with-custom-http-errors-and-default-backend-rule-0-path-0-retry", + }, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -3225,7 +3234,9 @@ func TestLoadIngresses(t *testing.T) { }, }, "default-ingress-with-custom-http-errors-and-default-backend-rule-0-path-0-retry": { - Retry: &dynamic.Retry{Attempts: 3}, + Retry: &dynamic.Retry{ + Attempts: 3, + }, }, }, Services: map[string]*dynamic.Service{ @@ -3296,10 +3307,13 @@ func TestLoadIngresses(t *testing.T) { HTTP: &dynamic.HTTPConfiguration{ Routers: map[string]*dynamic.Router{ "default-ingress-with-custom-http-errors-rule-0-path-0": { - Rule: "Host(`whoami.localhost`) && Path(`/`)", - RuleSyntax: "default", - Service: "default-ingress-with-custom-http-errors-whoami-80", - Middlewares: []string{"default-ingress-with-custom-http-errors-rule-0-path-0-custom-http-errors", "default-ingress-with-custom-http-errors-rule-0-path-0-retry"}, + Rule: "Host(`whoami.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-custom-http-errors-whoami-80", + Middlewares: []string{ + "default-ingress-with-custom-http-errors-rule-0-path-0-custom-http-errors", + "default-ingress-with-custom-http-errors-rule-0-path-0-retry", + }, }, "default-backend": { Rule: "PathPrefix(`/`)", @@ -3329,7 +3343,9 @@ func TestLoadIngresses(t *testing.T) { }, }, "default-ingress-with-custom-http-errors-rule-0-path-0-retry": { - Retry: &dynamic.Retry{Attempts: 3}, + Retry: &dynamic.Retry{ + Attempts: 3, + }, }, }, Services: map[string]*dynamic.Service{ @@ -3466,7 +3482,9 @@ func TestLoadIngresses(t *testing.T) { }, Middlewares: map[string]*dynamic.Middleware{ "default-ingress-with-default-backend-annotation-rule-0-path-0-retry": { - Retry: &dynamic.Retry{Attempts: 3}, + Retry: &dynamic.Retry{ + Attempts: 3, + }, }, }, Services: map[string]*dynamic.Service{ @@ -3997,6 +4015,713 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Server snippet with allowSnippetAnnotations enabled", + allowSnippetAnnotations: true, + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-server-snippet.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-server-snippet-rule-0-path-0": { + Rule: "Host(`snippet.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-server-snippet-whoami-80", + Middlewares: []string{ + "default-ingress-with-server-snippet-rule-0-path-0-snippet", + "default-ingress-with-server-snippet-rule-0-path-0-retry", + }, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-server-snippet-rule-0-path-0-snippet": { + Snippet: &dynamic.Snippet{ + ServerSnippet: "add_header X-Server-Snippet \"server-value\";\n", + }, + }, + "default-ingress-with-server-snippet-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-server-snippet-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-server-snippet", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-server-snippet": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Configuration snippet with allowSnippetAnnotations enabled", + allowSnippetAnnotations: true, + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-configuration-snippet.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-configuration-snippet-rule-0-path-0": { + Rule: "Host(`snippet.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-configuration-snippet-whoami-80", + Middlewares: []string{ + "default-ingress-with-configuration-snippet-rule-0-path-0-snippet", + "default-ingress-with-configuration-snippet-rule-0-path-0-retry", + }, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-configuration-snippet-rule-0-path-0-snippet": { + Snippet: &dynamic.Snippet{ + ConfigurationSnippet: "add_header X-Configuration-Snippet \"configuration-value\";\n", + }, + }, + "default-ingress-with-configuration-snippet-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-configuration-snippet-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-configuration-snippet", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-configuration-snippet": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Both snippets with allowSnippetAnnotations enabled", + allowSnippetAnnotations: true, + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-both-snippets.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-both-snippets-rule-0-path-0": { + Rule: "Host(`snippet.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-both-snippets-whoami-80", + Middlewares: []string{ + "default-ingress-with-both-snippets-rule-0-path-0-snippet", + "default-ingress-with-both-snippets-rule-0-path-0-retry", + }, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-both-snippets-rule-0-path-0-snippet": { + Snippet: &dynamic.Snippet{ + ServerSnippet: "add_header X-Server-Snippet \"server-value\";\n", + ConfigurationSnippet: "add_header X-Configuration-Snippet \"configuration-value\";\n", + }, + }, + "default-ingress-with-both-snippets-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-both-snippets-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-both-snippets", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-both-snippets": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Server snippet with allowSnippetAnnotations disabled", + allowSnippetAnnotations: false, + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-server-snippet.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-server-snippet-rule-0-path-0": { + Rule: "Host(`snippet.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-server-snippet-whoami-80", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-ingress-with-server-snippet-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-server-snippet", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-server-snippet": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Configuration snippet with allowSnippetAnnotations disabled", + allowSnippetAnnotations: false, + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-configuration-snippet.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-configuration-snippet-rule-0-path-0": { + Rule: "Host(`snippet.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-configuration-snippet-whoami-80", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-ingress-with-configuration-snippet-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-configuration-snippet", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-configuration-snippet": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Both snippets with allowSnippetAnnotations disabled", + allowSnippetAnnotations: false, + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-both-snippets.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-both-snippets-rule-0-path-0": { + Rule: "Host(`snippet.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-both-snippets-whoami-80", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-ingress-with-both-snippets-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-both-snippets", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-both-snippets": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Server snippet with allowSnippetAnnotations enabled", + allowSnippetAnnotations: true, + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-server-snippet.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-server-snippet-rule-0-path-0": { + Rule: "Host(`snippet.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-server-snippet-whoami-80", + Middlewares: []string{"default-ingress-with-server-snippet-rule-0-path-0-snippet", "default-ingress-with-server-snippet-rule-0-path-0-retry"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-server-snippet-rule-0-path-0-snippet": { + Snippet: &dynamic.Snippet{ + ServerSnippet: "add_header X-Server-Snippet \"server-value\";\n", + }, + }, + "default-ingress-with-server-snippet-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-server-snippet-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-server-snippet", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-server-snippet": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Configuration snippet with allowSnippetAnnotations enabled", + allowSnippetAnnotations: true, + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-configuration-snippet.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-configuration-snippet-rule-0-path-0": { + Rule: "Host(`snippet.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-configuration-snippet-whoami-80", + Middlewares: []string{"default-ingress-with-configuration-snippet-rule-0-path-0-snippet", "default-ingress-with-configuration-snippet-rule-0-path-0-retry"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-configuration-snippet-rule-0-path-0-snippet": { + Snippet: &dynamic.Snippet{ + ConfigurationSnippet: "add_header X-Configuration-Snippet \"configuration-value\";\n", + }, + }, + "default-ingress-with-configuration-snippet-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-configuration-snippet-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-configuration-snippet", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-configuration-snippet": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Both snippets with allowSnippetAnnotations enabled", + allowSnippetAnnotations: true, + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-both-snippets.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-both-snippets-rule-0-path-0": { + Rule: "Host(`snippet.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-both-snippets-whoami-80", + Middlewares: []string{"default-ingress-with-both-snippets-rule-0-path-0-snippet", "default-ingress-with-both-snippets-rule-0-path-0-retry"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-both-snippets-rule-0-path-0-snippet": { + Snippet: &dynamic.Snippet{ + ServerSnippet: "add_header X-Server-Snippet \"server-value\";\n", + ConfigurationSnippet: "add_header X-Configuration-Snippet \"configuration-value\";\n", + }, + }, + "default-ingress-with-both-snippets-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-both-snippets-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-both-snippets", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-both-snippets": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Server snippet with allowSnippetAnnotations disabled", + allowSnippetAnnotations: false, + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-server-snippet.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-server-snippet-rule-0-path-0": { + Rule: "Host(`snippet.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-server-snippet-whoami-80", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-ingress-with-server-snippet-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-server-snippet", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-server-snippet": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Configuration snippet with allowSnippetAnnotations disabled", + allowSnippetAnnotations: false, + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-configuration-snippet.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-configuration-snippet-rule-0-path-0": { + Rule: "Host(`snippet.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-configuration-snippet-whoami-80", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-ingress-with-configuration-snippet-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-configuration-snippet", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-configuration-snippet": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Both snippets with allowSnippetAnnotations disabled", + allowSnippetAnnotations: false, + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-both-snippets.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-both-snippets-rule-0-path-0": { + Rule: "Host(`snippet.localhost`) && Path(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-both-snippets-whoami-80", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-ingress-with-both-snippets-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-both-snippets", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-both-snippets": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "Auth TLS pass certificate to upstream", paths: []string{ @@ -4645,6 +5370,7 @@ func TestLoadIngresses(t *testing.T) { k8sClient: client, defaultBackendServiceName: test.defaultBackendServiceName, defaultBackendServiceNamespace: test.defaultBackendServiceNamespace, + AllowSnippetAnnotations: test.allowSnippetAnnotations, NonTLSEntryPoints: []string{"web"}, allowedHeaders: test.globalAllowedResponseHeaders, AllowCrossNamespaceResources: test.allowCrossNamespaceResources, diff --git a/pkg/server/middleware/middlewares.go b/pkg/server/middleware/middlewares.go index f67863db6f..9dade702e7 100644 --- a/pkg/server/middleware/middlewares.go +++ b/pkg/server/middleware/middlewares.go @@ -26,6 +26,7 @@ import ( "github.com/traefik/traefik/v3/pkg/middlewares/headers" "github.com/traefik/traefik/v3/pkg/middlewares/inflightreq" "github.com/traefik/traefik/v3/pkg/middlewares/ingressnginx/authtlspasscertificatetoupstream" + "github.com/traefik/traefik/v3/pkg/middlewares/ingressnginx/snippet" "github.com/traefik/traefik/v3/pkg/middlewares/ipallowlist" "github.com/traefik/traefik/v3/pkg/middlewares/ipwhitelist" "github.com/traefik/traefik/v3/pkg/middlewares/observability" @@ -428,6 +429,16 @@ func (b *Builder) buildConstructor(ctx context.Context, middlewareName string) ( } } + // ingress-nginx middlewares. + if config.Snippet != nil { + if middleware != nil { + return nil, badConf + } + middleware = func(next http.Handler) (http.Handler, error) { + return snippet.New(ctx, next, config.Snippet, middlewareName) + } + } + if middleware == nil { return nil, fmt.Errorf("invalid middleware %q configuration: invalid middleware type or middleware does not exist", middlewareName) }