diff --git a/docs/content/configuration/ingress-resources/advanced-configuration-with-annotations.md b/docs/content/configuration/ingress-resources/advanced-configuration-with-annotations.md index fa12b02ba9..a86d09700e 100644 --- a/docs/content/configuration/ingress-resources/advanced-configuration-with-annotations.md +++ b/docs/content/configuration/ingress-resources/advanced-configuration-with-annotations.md @@ -112,6 +112,7 @@ The table below summarizes the available annotations. |``nginx.org/proxy-buffer-size`` | ``proxy-buffer-size`` | Sets the value of the [proxy_buffer_size](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffer_size) and [grpc_buffer_size](https://nginx.org/en/docs/http/ngx_http_grpc_module.html#grpc_buffer_size) directives. | Depends on the platform. | | |``nginx.org/proxy-max-temp-file-size`` | ``proxy-max-temp-file-size`` | Sets the value of the [proxy_max_temp_file_size](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_max_temp_file_size) directive. | ``1024m`` | | |``nginx.org/server-tokens`` | ``server-tokens`` | Enables or disables the [server_tokens](https://nginx.org/en/docs/http/ngx_http_core_module.html#server_tokens) directive. Additionally, with the NGINX Plus, you can specify a custom string value, including the empty string value, which disables the emission of the “Server” field. | ``True`` | | +|``nginx.org/path-regex`` | N/A | Enables regular expression modifiers for [location](https://nginx.org/en/docs/http/ngx_http_core_module.html#location) directive. You can specify one of these values: "case_sensitive", "case_insensitive" or "exact". The annotation is applied to the entire Ingress resource and its paths. | | [Path Regex](https://github.com/nginxinc/kubernetes-ingress/tree/examples/ingress-resources/path-regex). | {{% /table %}} ### Request URI/Header Manipulation diff --git a/examples/ingress-resources/path-regex/README.md b/examples/ingress-resources/path-regex/README.md new file mode 100644 index 0000000000..b308d969fe --- /dev/null +++ b/examples/ingress-resources/path-regex/README.md @@ -0,0 +1,191 @@ +# Support for path regular expressions + +NGINX and NGINX Plus support regular expression modifiers for [location](https://nginx.org/en/docs/http/ngx_http_core_module.html#location) + directive. + +The NGINX Ingress Controller provides the following annotations for configuring regular expression support: + +- Optional: ```nginx.org/path-regex: "case_sensitive"``` - specifies a preceding regex modifier to be case sensitive (`~*`). +- Optional: ```nginx.org/path-regex: "case_insensitive"``` - specifies a preceding regex modifier to be case sensitive (`~`). +- Optional: ```nginx.org/path-regex: "exact"``` - specifies exact match preceding modifier (`=`). + +[NGINX documentation](https://docs.nginx.com/nginx/admin-guide/web-server/web-server/#nginx-location-priority) provides +additional information about how NGINX and NGINX Plus resolve location priority. +Read [it](https://docs.nginx.com/nginx/admin-guide/web-server/web-server/#nginx-location-priority) before using + the ``path-regex`` annotation. + +Nginx uses a specific syntax to decide which location block to use to handle a request. +Location blocks live within server blocks (or other location blocks) and are used to decide how to process + the request URI, for example: + +```bash +location optional_modifier location_match { + ... +} +``` + +The ``location_match`` defines what NGINX checks the request URI against. The existence or nonexistence of the modifier + in the example affects the way that the Nginx attempts to match the location block. + The modifiers you can apply using the ``path-regex`` annotation will cause the associated location block + to be interpreted as follows: + +- **no modifier** : No modifiers (no annotation applied) - the location is interpreted as a prefix match. +This means that the location given will be matched against the beginning of the request URI to determine a match + +- **~** : Tilde modifier (annotation value ``case_sensitive``) - the location is interpreted as a case-sensitive +regular expression match + +- **~***: Tilde and asterisk modifier (annotation value ``case_insensitive``) - the location is interpreted +as a case-insensitive regular expression match + +- **=** : Equal sign modifier (annotation value ``exact``) - the location is considered a match if the request + URI exactly matches the location provided. + +## Example 1: Case Sensitive RegEx + +In the following example you enable path regex annotation ``nginx.org/path-regex`` and set its value to `case_sensitive`. + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: cafe-ingress + annotations: + nginx.org/path-regex: "case_sensitive" +spec: + tls: + - hosts: + - cafe.example.com + secretName: cafe-secret + rules: + - host: cafe.example.com + http: + paths: + - path: /tea/[A-Z0-9] + backend: + serviceName: tea-svc + servicePort: 80 + - path: /coffee/[A-Z0-9] + backend: + serviceName: coffee-svc + servicePort: 80 +``` + +Corresponding NGINX config file snippet: + +```bash +... + + location ~ "^/tea/[A-Z0-9]" { + + set $service "tea-svc"; + status_zone "tea-svc"; + +... + + location ~ "^/coffee/[A-Z0-9]" { + + set $service "coffee-svc"; + status_zone "coffee-svc"; + +... +``` + +## Example 2: Case Insensitive RegEx + +In the following example you enable path regex annotation ``nginx.org/path-regex`` and set its value to `case_insensitive`. + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: cafe-ingress + annotations: + nginx.org/path-regex: "case_insensitive" +spec: + tls: + - hosts: + - cafe.example.com + secretName: cafe-secret + rules: + - host: cafe.example.com + http: + paths: + - path: /tea/[A-Z0-9] + backend: + serviceName: tea-svc + servicePort: 80 + - path: /coffee/[A-Z0-9] + backend: + serviceName: coffee-svc + servicePort: 80 +``` + +Corresponding NGINX config file snippet: + +```bash +... + + location ~* "^/tea/[A-Z0-9]" { + + set $service "tea-svc"; + status_zone "tea-svc"; + +... + + location ~* "^/coffee/[A-Z0-9]" { + + set $service "coffee-svc"; + status_zone "coffee-svc"; + +... +``` + +## Example 3: Exact RegEx + +In the following example you enable path regex annotation ``nginx.org/path-regex`` and set its value to `exact` match. + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: cafe-ingress + annotations: + nginx.org/path-regex: "exact" +spec: + tls: + - hosts: + - cafe.example.com + secretName: cafe-secret + rules: + - host: cafe.example.com + http: + paths: + - path: /tea/ + backend: + serviceName: tea-svc + servicePort: 80 + - path: /coffee/ + backend: + serviceName: coffee-svc + servicePort: 80 +``` + +Corresponding NGINX config file snippet: + +```bash +... + + location = "/tea" { + + set $service "tea-svc"; + status_zone "tea-svc"; + +... + + location = "/coffee" { + + set $service "coffee-svc"; + status_zone "coffee-svc"; +... +``` diff --git a/internal/configs/annotations.go b/internal/configs/annotations.go index 120e440f10..5dbcd662f0 100644 --- a/internal/configs/annotations.go +++ b/internal/configs/annotations.go @@ -10,6 +10,9 @@ const JWTKeyAnnotation = "nginx.com/jwt-key" // BasicAuthSecretAnnotation is the annotation where the Secret with the HTTP basic user list const BasicAuthSecretAnnotation = "nginx.org/basic-auth-secret" // #nosec G101 +// PathRegexAnnotation is the annotation where the regex location (path) modifier is specified. +const PathRegexAnnotation = "nginx.org/path-regex" + // AppProtectPolicyAnnotation is where the NGINX App Protect policy is specified const AppProtectPolicyAnnotation = "appprotect.f5.com/app-protect-policy" @@ -73,6 +76,12 @@ var minionInheritanceList = map[string]bool{ "nginx.org/fail-timeout": true, } +var validPathRegex = map[string]bool{ + "case_sensitive": true, + "case_insensitive": true, + "exact": true, +} + func parseAnnotations(ingEx *IngressEx, baseCfgParams *ConfigParams, isPlus bool, hasAppProtect bool, hasAppProtectDos bool, enableInternalRoutes bool) ConfigParams { cfgParams := *baseCfgParams @@ -385,6 +394,13 @@ func parseAnnotations(ingEx *IngressEx, baseCfgParams *ConfigParams, isPlus bool } } } + + if pathRegex, exists := ingEx.Ingress.Annotations[PathRegexAnnotation]; exists { + _, ok := validPathRegex[pathRegex] + if !ok { + glog.Errorf("Ingress %s/%s: Invalid value nginx.org/path-regex: got %q. Allowed values: 'case_sensitive', 'case_insensitive', 'exact'", ingEx.Ingress.GetNamespace(), ingEx.Ingress.GetName(), pathRegex) + } + } return cfgParams } diff --git a/internal/configs/version1/nginx-plus.ingress.tmpl b/internal/configs/version1/nginx-plus.ingress.tmpl index 2ceb881fec..54d4e980c9 100644 --- a/internal/configs/version1/nginx-plus.ingress.tmpl +++ b/internal/configs/version1/nginx-plus.ingress.tmpl @@ -177,7 +177,11 @@ server { {{end -}} {{range $location := $server.Locations}} - location {{$location.Path}} { + {{ if index $.Ingress.Annotations "nginx.org/path-regex" }} + location {{ makePathRegex $location.Path $.Ingress.Annotations | printf }} { + {{ else }} + location {{ $location.Path }} { + {{ end }} set $service "{{$location.ServiceName}}"; status_zone "{{ $location.ServiceName }}"; {{with $location.MinionIngress}} diff --git a/internal/configs/version1/nginx.ingress.tmpl b/internal/configs/version1/nginx.ingress.tmpl index e473f7731a..8c544a9581 100644 --- a/internal/configs/version1/nginx.ingress.tmpl +++ b/internal/configs/version1/nginx.ingress.tmpl @@ -102,7 +102,11 @@ server { {{- end}} {{range $location := $server.Locations}} - location {{$location.Path}} { + {{ if index $.Ingress.Annotations "nginx.org/path-regex" }} + location {{ makePathRegex $location.Path $.Ingress.Annotations | printf }} { + {{ else }} + location {{ $location.Path }} { + {{ end }} set $service "{{$location.ServiceName}}"; {{with $location.MinionIngress}} # location for minion {{$location.MinionIngress.Namespace}}/{{$location.MinionIngress.Name}} diff --git a/internal/configs/version1/template_helper.go b/internal/configs/version1/template_helper.go index c2b09f5c61..4857fd155e 100644 --- a/internal/configs/version1/template_helper.go +++ b/internal/configs/version1/template_helper.go @@ -1,6 +1,7 @@ package version1 import ( + "fmt" "strings" "text/template" ) @@ -13,7 +14,31 @@ func trim(s string) string { return strings.TrimSpace(s) } +// makePathRegex takes a string representing a location path +// and a map representing Ingress annotations. +// It returns a location path with added regular expression modifier. +// See [Location Directive]. +// +// [Location Directive]: https://nginx.org/en/docs/http/ngx_http_core_module.html#location +func makePathRegex(path string, annotations map[string]string) string { + p, ok := annotations["nginx.org/path-regex"] + if !ok { + return path + } + switch p { + case "case_sensitive": + return fmt.Sprintf("~ \"^%s\"", path) + case "case_insensitive": + return fmt.Sprintf("~* \"^%s\"", path) + case "exact": + return fmt.Sprintf("= \"%s\"", path) + default: + return path + } +} + var helperFunctions = template.FuncMap{ - "split": split, - "trim": trim, + "split": split, + "trim": trim, + "makePathRegex": makePathRegex, } diff --git a/internal/configs/version1/template_helper_test.go b/internal/configs/version1/template_helper_test.go new file mode 100644 index 0000000000..3304f50d8b --- /dev/null +++ b/internal/configs/version1/template_helper_test.go @@ -0,0 +1,115 @@ +package version1 + +import ( + "bytes" + "testing" + "text/template" +) + +func TestWithPathRegex_MatchesCaseSensitiveModifier(t *testing.T) { + t.Parallel() + + want := "~ \"^/coffee/[A-Z0-9]{3}\"" + got := makePathRegex("/coffee/[A-Z0-9]{3}", map[string]string{"nginx.org/path-regex": "case_sensitive"}) + if got != want { + t.Errorf("got: %s, want: %s", got, want) + } +} + +func TestWithPathRegex_MatchesCaseInsensitiveModifier(t *testing.T) { + t.Parallel() + + want := "~* \"^/coffee/[A-Z0-9]{3}\"" + got := makePathRegex("/coffee/[A-Z0-9]{3}", map[string]string{"nginx.org/path-regex": "case_insensitive"}) + if got != want { + t.Errorf("got: %s, want: %s", got, want) + } +} + +func TestWithPathReqex_MatchesExactModifier(t *testing.T) { + t.Parallel() + + want := "= \"/coffee\"" + got := makePathRegex("/coffee", map[string]string{"nginx.org/path-regex": "exact"}) + if got != want { + t.Errorf("got: %s, want: %s", got, want) + } +} + +func TestWithPathReqex_DoesNotMatchModifier(t *testing.T) { + t.Parallel() + + want := "/coffee" + got := makePathRegex("/coffee", map[string]string{"nginx.org/path-regex": "bogus"}) + if got != want { + t.Errorf("got: %s, want: %s", got, want) + } +} + +func TestWithPathReqex_DoesNotMatchEmptyModifier(t *testing.T) { + t.Parallel() + + want := "/coffee" + got := makePathRegex("/coffee", map[string]string{"nginx.org/path-regex": ""}) + if got != want { + t.Errorf("got: %s, want: %s", got, want) + } +} + +func TestWithPathReqex_DoesNotMatchBogusAnnotationName(t *testing.T) { + t.Parallel() + + want := "/coffee" + got := makePathRegex("/coffee", map[string]string{"nginx.org/bogus-annotation": ""}) + if got != want { + t.Errorf("got: %s, want: %s", got, want) + } +} + +func TestSplitHelperFunction(t *testing.T) { + t.Parallel() + const tpl = `{{range $n := split . ","}}{{$n}} {{end}}` + + tmpl, err := template.New("testTemplate").Funcs(helperFunctions).Parse(tpl) + if err != nil { + t.Fatalf("Failed to parse template: %v", err) + } + + var buf bytes.Buffer + + input := "foo,bar" + expected := "foo bar " + + err = tmpl.Execute(&buf, input) + if err != nil { + t.Fatalf("Failed to execute the template %v", err) + } + + if buf.String() != expected { + t.Fatalf("Template generated wrong config, got %v but expected %v.", buf.String(), expected) + } +} + +func TestTrimHelperFunction(t *testing.T) { + t.Parallel() + const tpl = `{{trim .}}` + + tmpl, err := template.New("testTemplate").Funcs(helperFunctions).Parse(tpl) + if err != nil { + t.Fatalf("Failed to parse template: %v", err) + } + + var buf bytes.Buffer + + input := " foobar " + expected := "foobar" + + err = tmpl.Execute(&buf, input) + if err != nil { + t.Fatalf("Failed to execute the template %v", err) + } + + if buf.String() != expected { + t.Fatalf("Template generated wrong config, got %v but expected %v.", buf.String(), expected) + } +} diff --git a/internal/configs/version1/templates_test.go b/internal/configs/version1/templates_test.go index 5b5c9417f9..137be17578 100644 --- a/internal/configs/version1/templates_test.go +++ b/internal/configs/version1/templates_test.go @@ -2,149 +2,118 @@ package version1 import ( "bytes" + "strings" "testing" "text/template" ) -const ( - nginxIngressTmpl = "nginx.ingress.tmpl" - nginxMainTmpl = "nginx.tmpl" - nginxPlusIngressTmpl = "nginx-plus.ingress.tmpl" - nginxPlusMainTmpl = "nginx-plus.tmpl" -) - -var testUps = Upstream{ - Name: "test", - UpstreamZoneSize: "256k", - UpstreamServers: []UpstreamServer{ - { - Address: "127.0.0.1:8181", - MaxFails: 0, - MaxConns: 0, - FailTimeout: "1s", - SlowStart: "5s", - }, - }, +func makeTemplateNGINXPlus(t *testing.T) *template.Template { + t.Helper() + tmpl, err := template.New(nginxPlusIngressTmpl).Funcs(helperFunctions).ParseFiles(nginxPlusIngressTmpl) + if err != nil { + t.Fatal(err) + } + return tmpl } -var ( - headers = map[string]string{"Test-Header": "test-header-value"} - healthCheck = HealthCheck{ - UpstreamName: "test", - Fails: 1, - Interval: 1, - Passes: 1, - Headers: headers, +func makeTemplateNGINX(t *testing.T) *template.Template { + t.Helper() + tmpl, err := template.New(nginxIngressTmpl).Funcs(helperFunctions).ParseFiles(nginxIngressTmpl) + if err != nil { + t.Fatal(err) } -) + return tmpl +} -var ingCfg = IngressNginxConfig{ - Servers: []Server{ - { - Name: "test.example.com", - ServerTokens: "off", - StatusZone: "test.example.com", - JWTAuth: &JWTAuth{ - Key: "/etc/nginx/secrets/key.jwk", - Realm: "closed site", - Token: "$cookie_auth_token", - RedirectLocationName: "@login_url-default-cafe-ingress", - }, - SSL: true, - SSLCertificate: "secret.pem", - SSLCertificateKey: "secret.pem", - SSLPorts: []int{443}, - SSLRedirect: true, - Locations: []Location{ - { - Path: "/tea", - Upstream: testUps, - ProxyConnectTimeout: "10s", - ProxyReadTimeout: "10s", - ProxySendTimeout: "10s", - ClientMaxBodySize: "2m", - JWTAuth: &JWTAuth{ - Key: "/etc/nginx/secrets/location-key.jwk", - Realm: "closed site", - Token: "$cookie_auth_token", - }, - MinionIngress: &Ingress{ - Name: "tea-minion", - Namespace: "default", - }, - }, - }, - HealthChecks: map[string]HealthCheck{"test": healthCheck}, - JWTRedirectLocations: []JWTRedirectLocation{ - { - Name: "@login_url-default-cafe-ingress", - LoginURL: "https://test.example.com/login", - }, - }, - }, - }, - Upstreams: []Upstream{testUps}, - Keepalive: "16", - Ingress: Ingress{ - Name: "cafe-ingress", - Namespace: "default", - }, +func TestIngressForNGINXPlus(t *testing.T) { + t.Parallel() + tmpl := makeTemplateNGINXPlus(t) + buf := &bytes.Buffer{} + err := tmpl.Execute(buf, ingressCfg) + t.Log(buf.String()) + if err != nil { + t.Fatalf("Failed to write template %v", err) + } } -var mainCfg = MainConfig{ - ServerNamesHashMaxSize: "512", - ServerTokens: "off", - WorkerProcesses: "auto", - WorkerCPUAffinity: "auto", - WorkerShutdownTimeout: "1m", - WorkerConnections: "1024", - WorkerRlimitNofile: "65536", - LogFormat: []string{"$remote_addr", "$remote_user"}, - LogFormatEscaping: "default", - StreamSnippets: []string{"# comment"}, - StreamLogFormat: []string{"$remote_addr", "$remote_user"}, - StreamLogFormatEscaping: "none", - ResolverAddresses: []string{"example.com", "127.0.0.1"}, - ResolverIPV6: false, - ResolverValid: "10s", - ResolverTimeout: "15s", - KeepaliveTimeout: "65s", - KeepaliveRequests: 100, - VariablesHashBucketSize: 256, - VariablesHashMaxSize: 1024, - TLSPassthrough: true, +func TestIngressForNGINX(t *testing.T) { + t.Parallel() + tmpl := makeTemplateNGINX(t) + buf := &bytes.Buffer{} + + err := tmpl.Execute(buf, ingressCfg) + t.Log(buf.String()) + if err != nil { + t.Fatalf("Failed to write template %v", err) + } } -func TestIngressForNGINXPlus(t *testing.T) { +func TestExecuteTemplate_ForIngressForNGINXPlusWithRegExAnnotationCaseSensitive(t *testing.T) { t.Parallel() - tmpl, err := template.New(nginxPlusIngressTmpl).Funcs(helperFunctions).ParseFiles(nginxPlusIngressTmpl) + tmpl := makeTemplateNGINXPlus(t) + buf := &bytes.Buffer{} + + err := tmpl.Execute(buf, ingressCfgWithRegExAnnotationCaseSensitive) + t.Log(buf.String()) if err != nil { - t.Fatalf("Failed to parse template file: %v", err) + t.Fatalf("Failed to write template %v", err) } - var buf bytes.Buffer + wantLocation := "~ \"^/tea/[A-Z0-9]{3}\"" + if !strings.Contains(buf.String(), wantLocation) { + t.Errorf("want %q in generated config", wantLocation) + } +} - err = tmpl.Execute(&buf, ingCfg) +func TestExecuteTemplate_ForIngressForNGINXPlusWithRegExAnnotationCaseInsensitive(t *testing.T) { + t.Parallel() + tmpl := makeTemplateNGINXPlus(t) + buf := &bytes.Buffer{} + + err := tmpl.Execute(buf, ingressCfgWithRegExAnnotationCaseInsensitive) t.Log(buf.String()) if err != nil { t.Fatalf("Failed to write template %v", err) } + + wantLocation := "~* \"^/tea/[A-Z0-9]{3}\"" + if !strings.Contains(buf.String(), wantLocation) { + t.Errorf("want %q in generated config", wantLocation) + } } -func TestIngressForNGINX(t *testing.T) { +func TestExecuteTemplate_ForIngressForNGINXPlusWithRegExAnnotationExactMatch(t *testing.T) { t.Parallel() - tmpl, err := template.New(nginxIngressTmpl).Funcs(helperFunctions).ParseFiles(nginxIngressTmpl) + tmpl := makeTemplateNGINXPlus(t) + buf := &bytes.Buffer{} + + err := tmpl.Execute(buf, ingressCfgWithRegExAnnotationExactMatch) + t.Log(buf.String()) if err != nil { - t.Fatalf("Failed to parse template file: %v", err) + t.Fatalf("Failed to write template %v", err) } - var buf bytes.Buffer + wantLocation := "= \"/tea\"" + if !strings.Contains(buf.String(), wantLocation) { + t.Errorf("want %q in generated config", wantLocation) + } +} + +func TestExecuteTemplate_ForIngressForNGINXPlusWithRegExAnnotationEmpty(t *testing.T) { + t.Parallel() + tmpl := makeTemplateNGINXPlus(t) + buf := &bytes.Buffer{} - err = tmpl.Execute(&buf, ingCfg) + err := tmpl.Execute(buf, ingressCfgWithRegExAnnotationEmptyString) t.Log(buf.String()) if err != nil { t.Fatalf("Failed to write template %v", err) } + + wantLocation := "/tea" + if !strings.Contains(buf.String(), wantLocation) { + t.Errorf("want %q in generated config", wantLocation) + } } func TestMainForNGINXPlus(t *testing.T) { @@ -153,10 +122,9 @@ func TestMainForNGINXPlus(t *testing.T) { if err != nil { t.Fatalf("Failed to parse template file: %v", err) } + buf := &bytes.Buffer{} - var buf bytes.Buffer - - err = tmpl.Execute(&buf, mainCfg) + err = tmpl.Execute(buf, mainCfg) t.Log(buf.String()) if err != nil { t.Fatalf("Failed to write template %v", err) @@ -169,60 +137,343 @@ func TestMainForNGINX(t *testing.T) { if err != nil { t.Fatalf("Failed to parse template file: %v", err) } + buf := &bytes.Buffer{} - var buf bytes.Buffer - - err = tmpl.Execute(&buf, mainCfg) + err = tmpl.Execute(buf, mainCfg) t.Log(buf.String()) if err != nil { t.Fatalf("Failed to write template %v", err) } } -func TestSplitHelperFunction(t *testing.T) { - t.Parallel() - const tpl = `{{range $n := split . ","}}{{$n}} {{end}}` - - tmpl, err := template.New("testTemplate").Funcs(helperFunctions).Parse(tpl) - if err != nil { - t.Fatalf("Failed to parse template: %v", err) +var ( + // Ingress Config example without added annotations + ingressCfg = IngressNginxConfig{ + Servers: []Server{ + { + Name: "test.example.com", + ServerTokens: "off", + StatusZone: "test.example.com", + JWTAuth: &JWTAuth{ + Key: "/etc/nginx/secrets/key.jwk", + Realm: "closed site", + Token: "$cookie_auth_token", + RedirectLocationName: "@login_url-default-cafe-ingress", + }, + SSL: true, + SSLCertificate: "secret.pem", + SSLCertificateKey: "secret.pem", + SSLPorts: []int{443}, + SSLRedirect: true, + Locations: []Location{ + { + Path: "/tea", + Upstream: testUpstream, + ProxyConnectTimeout: "10s", + ProxyReadTimeout: "10s", + ProxySendTimeout: "10s", + ClientMaxBodySize: "2m", + JWTAuth: &JWTAuth{ + Key: "/etc/nginx/secrets/location-key.jwk", + Realm: "closed site", + Token: "$cookie_auth_token", + }, + MinionIngress: &Ingress{ + Name: "tea-minion", + Namespace: "default", + }, + }, + }, + HealthChecks: map[string]HealthCheck{"test": healthCheck}, + JWTRedirectLocations: []JWTRedirectLocation{ + { + Name: "@login_url-default-cafe-ingress", + LoginURL: "https://test.example.com/login", + }, + }, + }, + }, + Upstreams: []Upstream{testUpstream}, + Keepalive: "16", + Ingress: Ingress{ + Name: "cafe-ingress", + Namespace: "default", + }, } - var buf bytes.Buffer - - input := "foo,bar" - expected := "foo bar " - - err = tmpl.Execute(&buf, input) - if err != nil { - t.Fatalf("Failed to execute the template %v", err) + // Ingress Config example with path-regex annotation value "case_sensitive" + ingressCfgWithRegExAnnotationCaseSensitive = IngressNginxConfig{ + Servers: []Server{ + { + Name: "test.example.com", + ServerTokens: "off", + StatusZone: "test.example.com", + JWTAuth: &JWTAuth{ + Key: "/etc/nginx/secrets/key.jwk", + Realm: "closed site", + Token: "$cookie_auth_token", + RedirectLocationName: "@login_url-default-cafe-ingress", + }, + SSL: true, + SSLCertificate: "secret.pem", + SSLCertificateKey: "secret.pem", + SSLPorts: []int{443}, + SSLRedirect: true, + Locations: []Location{ + { + Path: "/tea/[A-Z0-9]{3}", + Upstream: testUpstream, + ProxyConnectTimeout: "10s", + ProxyReadTimeout: "10s", + ProxySendTimeout: "10s", + ClientMaxBodySize: "2m", + JWTAuth: &JWTAuth{ + Key: "/etc/nginx/secrets/location-key.jwk", + Realm: "closed site", + Token: "$cookie_auth_token", + }, + MinionIngress: &Ingress{ + Name: "tea-minion", + Namespace: "default", + }, + }, + }, + HealthChecks: map[string]HealthCheck{"test": healthCheck}, + JWTRedirectLocations: []JWTRedirectLocation{ + { + Name: "@login_url-default-cafe-ingress", + LoginURL: "https://test.example.com/login", + }, + }, + }, + }, + Upstreams: []Upstream{testUpstream}, + Keepalive: "16", + Ingress: Ingress{ + Name: "cafe-ingress", + Namespace: "default", + Annotations: map[string]string{"nginx.org/path-regex": "case_sensitive"}, + }, } - if buf.String() != expected { - t.Fatalf("Template generated wrong config, got %v but expected %v.", buf.String(), expected) + // Ingress Config example with path-regex annotation value "case_insensitive" + ingressCfgWithRegExAnnotationCaseInsensitive = IngressNginxConfig{ + Servers: []Server{ + { + Name: "test.example.com", + ServerTokens: "off", + StatusZone: "test.example.com", + JWTAuth: &JWTAuth{ + Key: "/etc/nginx/secrets/key.jwk", + Realm: "closed site", + Token: "$cookie_auth_token", + RedirectLocationName: "@login_url-default-cafe-ingress", + }, + SSL: true, + SSLCertificate: "secret.pem", + SSLCertificateKey: "secret.pem", + SSLPorts: []int{443}, + SSLRedirect: true, + Locations: []Location{ + { + Path: "/tea/[A-Z0-9]{3}", + Upstream: testUpstream, + ProxyConnectTimeout: "10s", + ProxyReadTimeout: "10s", + ProxySendTimeout: "10s", + ClientMaxBodySize: "2m", + JWTAuth: &JWTAuth{ + Key: "/etc/nginx/secrets/location-key.jwk", + Realm: "closed site", + Token: "$cookie_auth_token", + }, + MinionIngress: &Ingress{ + Name: "tea-minion", + Namespace: "default", + }, + }, + }, + HealthChecks: map[string]HealthCheck{"test": healthCheck}, + JWTRedirectLocations: []JWTRedirectLocation{ + { + Name: "@login_url-default-cafe-ingress", + LoginURL: "https://test.example.com/login", + }, + }, + }, + }, + Upstreams: []Upstream{testUpstream}, + Keepalive: "16", + Ingress: Ingress{ + Name: "cafe-ingress", + Namespace: "default", + Annotations: map[string]string{"nginx.org/path-regex": "case_insensitive"}, + }, } -} -func TestTrimHelperFunction(t *testing.T) { - t.Parallel() - const tpl = `{{trim .}}` + // Ingress Config example with path-regex annotation value "exact" + ingressCfgWithRegExAnnotationExactMatch = IngressNginxConfig{ + Servers: []Server{ + { + Name: "test.example.com", + ServerTokens: "off", + StatusZone: "test.example.com", + JWTAuth: &JWTAuth{ + Key: "/etc/nginx/secrets/key.jwk", + Realm: "closed site", + Token: "$cookie_auth_token", + RedirectLocationName: "@login_url-default-cafe-ingress", + }, + SSL: true, + SSLCertificate: "secret.pem", + SSLCertificateKey: "secret.pem", + SSLPorts: []int{443}, + SSLRedirect: true, + Locations: []Location{ + { + Path: "/tea", + Upstream: testUpstream, + ProxyConnectTimeout: "10s", + ProxyReadTimeout: "10s", + ProxySendTimeout: "10s", + ClientMaxBodySize: "2m", + JWTAuth: &JWTAuth{ + Key: "/etc/nginx/secrets/location-key.jwk", + Realm: "closed site", + Token: "$cookie_auth_token", + }, + MinionIngress: &Ingress{ + Name: "tea-minion", + Namespace: "default", + }, + }, + }, + HealthChecks: map[string]HealthCheck{"test": healthCheck}, + JWTRedirectLocations: []JWTRedirectLocation{ + { + Name: "@login_url-default-cafe-ingress", + LoginURL: "https://test.example.com/login", + }, + }, + }, + }, + Upstreams: []Upstream{testUpstream}, + Keepalive: "16", + Ingress: Ingress{ + Name: "cafe-ingress", + Namespace: "default", + Annotations: map[string]string{"nginx.org/path-regex": "exact"}, + }, + } - tmpl, err := template.New("testTemplate").Funcs(helperFunctions).Parse(tpl) - if err != nil { - t.Fatalf("Failed to parse template: %v", err) + // Ingress Config example with path-regex annotation value of an empty string + ingressCfgWithRegExAnnotationEmptyString = IngressNginxConfig{ + Servers: []Server{ + { + Name: "test.example.com", + ServerTokens: "off", + StatusZone: "test.example.com", + JWTAuth: &JWTAuth{ + Key: "/etc/nginx/secrets/key.jwk", + Realm: "closed site", + Token: "$cookie_auth_token", + RedirectLocationName: "@login_url-default-cafe-ingress", + }, + SSL: true, + SSLCertificate: "secret.pem", + SSLCertificateKey: "secret.pem", + SSLPorts: []int{443}, + SSLRedirect: true, + Locations: []Location{ + { + Path: "/tea", + Upstream: testUpstream, + ProxyConnectTimeout: "10s", + ProxyReadTimeout: "10s", + ProxySendTimeout: "10s", + ClientMaxBodySize: "2m", + JWTAuth: &JWTAuth{ + Key: "/etc/nginx/secrets/location-key.jwk", + Realm: "closed site", + Token: "$cookie_auth_token", + }, + MinionIngress: &Ingress{ + Name: "tea-minion", + Namespace: "default", + }, + }, + }, + HealthChecks: map[string]HealthCheck{"test": healthCheck}, + JWTRedirectLocations: []JWTRedirectLocation{ + { + Name: "@login_url-default-cafe-ingress", + LoginURL: "https://test.example.com/login", + }, + }, + }, + }, + Upstreams: []Upstream{testUpstream}, + Keepalive: "16", + Ingress: Ingress{ + Name: "cafe-ingress", + Namespace: "default", + Annotations: map[string]string{"nginx.org/path-regex": ""}, + }, } - var buf bytes.Buffer + mainCfg = MainConfig{ + ServerNamesHashMaxSize: "512", + ServerTokens: "off", + WorkerProcesses: "auto", + WorkerCPUAffinity: "auto", + WorkerShutdownTimeout: "1m", + WorkerConnections: "1024", + WorkerRlimitNofile: "65536", + LogFormat: []string{"$remote_addr", "$remote_user"}, + LogFormatEscaping: "default", + StreamSnippets: []string{"# comment"}, + StreamLogFormat: []string{"$remote_addr", "$remote_user"}, + StreamLogFormatEscaping: "none", + ResolverAddresses: []string{"example.com", "127.0.0.1"}, + ResolverIPV6: false, + ResolverValid: "10s", + ResolverTimeout: "15s", + KeepaliveTimeout: "65s", + KeepaliveRequests: 100, + VariablesHashBucketSize: 256, + VariablesHashMaxSize: 1024, + TLSPassthrough: true, + } +) - input := " foobar " - expected := "foobar" +const ( + nginxIngressTmpl = "nginx.ingress.tmpl" + nginxMainTmpl = "nginx.tmpl" + nginxPlusIngressTmpl = "nginx-plus.ingress.tmpl" + nginxPlusMainTmpl = "nginx-plus.tmpl" +) - err = tmpl.Execute(&buf, input) - if err != nil { - t.Fatalf("Failed to execute the template %v", err) - } +var testUpstream = Upstream{ + Name: "test", + UpstreamZoneSize: "256k", + UpstreamServers: []UpstreamServer{ + { + Address: "127.0.0.1:8181", + MaxFails: 0, + MaxConns: 0, + FailTimeout: "1s", + SlowStart: "5s", + }, + }, +} - if buf.String() != expected { - t.Fatalf("Template generated wrong config, got %v but expected %v.", buf.String(), expected) +var ( + headers = map[string]string{"Test-Header": "test-header-value"} + healthCheck = HealthCheck{ + UpstreamName: "test", + Fails: 1, + Interval: 1, + Passes: 1, + Headers: headers, } -} +) diff --git a/internal/k8s/validation.go b/internal/k8s/validation.go index 1276c38d39..2690a5c6d0 100644 --- a/internal/k8s/validation.go +++ b/internal/k8s/validation.go @@ -67,6 +67,7 @@ const ( grpcServicesAnnotation = "nginx.org/grpc-services" rewritesAnnotation = "nginx.org/rewrites" stickyCookieServicesAnnotation = "nginx.com/sticky-cookie-services" + pathRegexAnnotation = "nginx.org/path-regex" ) const ( @@ -327,10 +328,22 @@ var ( validateRequiredAnnotation, validateStickyServiceListAnnotation, }, + pathRegexAnnotation: { + validatePathRegex, + }, } annotationNames = sortedAnnotationNames(annotationValidations) ) +func validatePathRegex(context *annotationValidationContext) field.ErrorList { + switch context.value { + case "case_sensitive", "case_insensitive", "exact": + return nil + default: + return field.ErrorList{field.Invalid(context.fieldPath, context.value, "allowed values: 'case_sensitive', 'case_insensitive' or 'exact'")} + } +} + func validateJWTLoginURLAnnotation(context *annotationValidationContext) field.ErrorList { allErrs := field.ErrorList{} diff --git a/internal/k8s/validation_test.go b/internal/k8s/validation_test.go index cc48a12d71..2080dc1b0c 100644 --- a/internal/k8s/validation_test.go +++ b/internal/k8s/validation_test.go @@ -12,6 +12,272 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" ) +func TestValidateIngress_WithValidPathRegexValuesForNGINXPlus(t *testing.T) { + t.Parallel() + tt := []struct { + name string + ingress *networking.Ingress + isPlus bool + }{ + { + name: "case sensitive path regex", + ingress: &networking.Ingress{ + ObjectMeta: meta_v1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.org/path-regex": "case_sensitive", + }, + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "example.com", + }, + }, + }, + }, + isPlus: true, + }, + { + name: "case insensitive path regex", + ingress: &networking.Ingress{ + ObjectMeta: meta_v1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.org/path-regex": "case_insensitive", + }, + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "example.com", + }, + }, + }, + }, + isPlus: true, + }, + { + name: "exact path regex", + ingress: &networking.Ingress{ + ObjectMeta: meta_v1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.org/path-regex": "exact", + }, + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "example.com", + }, + }, + }, + }, + isPlus: true, + }, + } + + for _, tc := range tt { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + allErrs := validateIngress(tc.ingress, tc.isPlus, false, false, false, false) + if len(allErrs) != 0 { + t.Errorf("want no errors, got %+v\n", allErrs) + } + }) + } +} + +func TestValidateIngress_WithValidPathRegexValuesForNGINX(t *testing.T) { + t.Parallel() + tt := []struct { + name string + ingress *networking.Ingress + isPlus bool + }{ + { + name: "case sensitive path regex", + ingress: &networking.Ingress{ + ObjectMeta: meta_v1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.org/path-regex": "case_sensitive", + }, + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "example.com", + }, + }, + }, + }, + isPlus: false, + }, + { + name: "case insensitive path regex", + ingress: &networking.Ingress{ + ObjectMeta: meta_v1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.org/path-regex": "case_insensitive", + }, + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "example.com", + }, + }, + }, + }, + isPlus: false, + }, + { + name: "exact path regex", + ingress: &networking.Ingress{ + ObjectMeta: meta_v1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.org/path-regex": "exact", + }, + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "example.com", + }, + }, + }, + }, + isPlus: false, + }, + } + + for _, tc := range tt { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + allErrs := validateIngress(tc.ingress, tc.isPlus, false, false, false, false) + if len(allErrs) != 0 { + t.Errorf("want no errors, got %+v\n", allErrs) + } + }) + } +} + +func TestValidateIngress_WithInvalidPathRegexValuesForNGINXPlus(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + ingress *networking.Ingress + isPlus bool + }{ + { + name: "bogus not empty path regex string", + ingress: &networking.Ingress{ + ObjectMeta: meta_v1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.org/path-regex": "bogus", + }, + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "example.com", + }, + }, + }, + }, + isPlus: true, + }, + { + name: "bogus empty path regex string", + ingress: &networking.Ingress{ + ObjectMeta: meta_v1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.org/path-regex": "", + }, + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "example.com", + }, + }, + }, + }, + isPlus: true, + }, + } + for _, tc := range tt { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + allErrs := validateIngress(tc.ingress, tc.isPlus, false, false, false, false) + if len(allErrs) == 0 { + t.Error("want errors on invalid path regex values") + } + t.Log(allErrs) + }) + } +} + +func TestValidateIngress_WithInvalidPathRegexValuesForNGINX(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + ingress *networking.Ingress + isPlus bool + }{ + { + name: "bogus not empty path regex string", + ingress: &networking.Ingress{ + ObjectMeta: meta_v1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.org/path-regex": "bogus", + }, + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "example.com", + }, + }, + }, + }, + isPlus: false, + }, + { + name: "bogus empty path regex string", + ingress: &networking.Ingress{ + ObjectMeta: meta_v1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.org/path-regex": "", + }, + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "example.com", + }, + }, + }, + }, + isPlus: false, + }, + } + for _, tc := range tt { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + allErrs := validateIngress(tc.ingress, tc.isPlus, false, false, false, false) + if len(allErrs) == 0 { + t.Error("want errors on invalid path regex values") + } + t.Log(allErrs) + }) + } +} + func TestValidateIngress(t *testing.T) { t.Parallel() tests := []struct {