diff --git a/internal/configs/annotations.go b/internal/configs/annotations.go index 8e1f58ef85..4e8ea29e61 100644 --- a/internal/configs/annotations.go +++ b/internal/configs/annotations.go @@ -503,6 +503,10 @@ func parseAnnotations(ingEx *IngressEx, baseCfgParams *ConfigParams, isPlus bool } } + if appRoot, exists := ingEx.Ingress.Annotations["nginx.org/app-root"]; exists { + cfgParams.AppRoot = appRoot + } + if useClusterIP, exists, err := GetMapKeyAsBool(ingEx.Ingress.Annotations, UseClusterIPAnnotation, ingEx.Ingress); exists { if err != nil { nl.Error(l, err) diff --git a/internal/configs/config_params.go b/internal/configs/config_params.go index 82e32944da..a477ad63be 100644 --- a/internal/configs/config_params.go +++ b/internal/configs/config_params.go @@ -10,6 +10,7 @@ import ( // ConfigParams holds NGINX configuration parameters that affect the main NGINX config // as well as configs for Ingress resources. type ConfigParams struct { + AppRoot string Context context.Context ClientMaxBodySize string ClientBodyBufferSize string diff --git a/internal/configs/ingress.go b/internal/configs/ingress.go index 0b4fc2d12b..54b5e0ffad 100644 --- a/internal/configs/ingress.go +++ b/internal/configs/ingress.go @@ -184,6 +184,7 @@ func generateNginxCfg(p NginxCfgParams) (version1.IngressNginxConfig, Warnings) AppProtectLogEnable: cfgParams.AppProtectLogEnable, SpiffeCerts: cfgParams.SpiffeServerCerts, DisableIPV6: p.staticParams.DisableIPV6, + AppRoot: cfgParams.AppRoot, } warnings := addSSLConfig(&server, p.ingEx.Ingress, rule.Host, p.ingEx.Ingress.Spec.TLS, p.ingEx.SecretRefs, p.isWildcardEnabled) diff --git a/internal/configs/ingress_test.go b/internal/configs/ingress_test.go index 2e5ddb5f2e..cf051f6940 100644 --- a/internal/configs/ingress_test.go +++ b/internal/configs/ingress_test.go @@ -139,6 +139,37 @@ func TestGenerateNginxCfgForBasicAuth(t *testing.T) { } } +func TestGenerateNginxCfgForAppRoot(t *testing.T) { + t.Parallel() + cafeIngressEx := createCafeIngressEx() + cafeIngressEx.Ingress.Annotations["nginx.org/app-root"] = "/coffee" + + isPlus := false + configParams := NewDefaultConfigParams(context.Background(), isPlus) + + expected := createExpectedConfigForCafeIngressEx(isPlus) + expected.Servers[0].AppRoot = "/coffee" + + result, warnings := generateNginxCfg(NginxCfgParams{ + staticParams: &StaticConfigParams{}, + ingEx: &cafeIngressEx, + apResources: nil, + dosResource: nil, + isMinion: false, + isPlus: isPlus, + BaseCfgParams: configParams, + isResolverConfigured: false, + isWildcardEnabled: false, + }) + + if result.Servers[0].AppRoot != expected.Servers[0].AppRoot { + t.Errorf("generateNginxCfg returned AppRoot %v, but expected %v", result.Servers[0].AppRoot, expected.Servers[0].AppRoot) + } + if len(warnings) != 0 { + t.Errorf("generateNginxCfg returned warnings: %v", warnings) + } +} + func TestGenerateNginxCfgWithMissingTLSSecret(t *testing.T) { t.Parallel() cafeIngressEx := createCafeIngressEx() diff --git a/internal/configs/version1/config.go b/internal/configs/version1/config.go index 317157db97..fe8840245f 100644 --- a/internal/configs/version1/config.go +++ b/internal/configs/version1/config.go @@ -132,6 +132,8 @@ type Server struct { SpiffeCerts bool DisableIPV6 bool + + AppRoot string } // JWTRedirectLocation describes a location for redirecting client requests to a login URL for JWT Authentication. diff --git a/internal/configs/version1/nginx-plus.ingress.tmpl b/internal/configs/version1/nginx-plus.ingress.tmpl index 25b2049e5d..233680317c 100644 --- a/internal/configs/version1/nginx-plus.ingress.tmpl +++ b/internal/configs/version1/nginx-plus.ingress.tmpl @@ -172,6 +172,12 @@ server { {{$value}}{{end}} {{- end}} + {{- if $server.AppRoot }} + if ($uri = /) { + return 302 $scheme://$http_host{{ $server.AppRoot }}; + } + {{- end }} + {{- range $healthCheck := $server.HealthChecks}} location @hc-{{$healthCheck.UpstreamName}} { {{- range $name, $header := $healthCheck.Headers}} diff --git a/internal/configs/version1/nginx.ingress.tmpl b/internal/configs/version1/nginx.ingress.tmpl index 70d8f2cfa5..5c6d02c9bf 100644 --- a/internal/configs/version1/nginx.ingress.tmpl +++ b/internal/configs/version1/nginx.ingress.tmpl @@ -116,6 +116,12 @@ server { {{- range $value := $server.ServerSnippets}} {{$value}}{{- end}} + {{- if $server.AppRoot }} + if ($uri = /) { + return 302 $scheme://$http_host{{ $server.AppRoot }}; + } + {{- end }} + {{- range $location := $server.Locations}} location {{ makeLocationPath $location $.Ingress.Annotations | printf }} { set $service "{{$location.ServiceName}}"; diff --git a/internal/k8s/validation.go b/internal/k8s/validation.go index b0baec9992..087cd178dc 100644 --- a/internal/k8s/validation.go +++ b/internal/k8s/validation.go @@ -75,6 +75,7 @@ const ( stickyCookieServicesAnnotation = "nginx.com/sticky-cookie-services" pathRegexAnnotation = "nginx.org/path-regex" useClusterIPAnnotation = "nginx.org/use-cluster-ip" + appRootAnnotation = "nginx.org/app-root" ) const ( @@ -360,6 +361,9 @@ var ( useClusterIPAnnotation: { validateBoolAnnotation, }, + appRootAnnotation: { + validateAppRootAnnotation, + }, } annotationNames = sortedAnnotationNames(annotationValidations) ) @@ -373,6 +377,40 @@ func validatePathRegex(context *annotationValidationContext) field.ErrorList { } } +func validateAppRootAnnotation(context *annotationValidationContext) field.ErrorList { + allErrs := field.ErrorList{} + + path := context.value + + // App root must start with / + if !strings.HasPrefix(path, "/") { + allErrs = append(allErrs, field.Invalid(context.fieldPath, path, "must start with '/'")) + return allErrs + } + + // App root cannot be just "/" + if path == "/" { + allErrs = append(allErrs, field.Invalid(context.fieldPath, path, "cannot be '/'")) + return allErrs + } + + // Validate that the path doesn't contain invalid characters + // Allow alphanumeric, hyphens, underscores, dots, and forward slashes + validPath := regexp.MustCompile(`^/[a-zA-Z0-9\-_./]*$`) + if !validPath.MatchString(path) { + allErrs = append(allErrs, field.Invalid(context.fieldPath, path, "contains invalid characters, only alphanumeric, hyphens, underscores, dots, and forward slashes are allowed")) + return allErrs + } + + // Ensure path doesn't end with / + if strings.HasSuffix(path, "/") { + allErrs = append(allErrs, field.Invalid(context.fieldPath, path, "path should not end with '/'")) + return allErrs + } + + return allErrs +} + func validateJWTLoginURLAnnotation(context *annotationValidationContext) field.ErrorList { allErrs := field.ErrorList{} diff --git a/internal/k8s/validation_test.go b/internal/k8s/validation_test.go index 72a1e9c9e1..9af10a4952 100644 --- a/internal/k8s/validation_test.go +++ b/internal/k8s/validation_test.go @@ -3409,6 +3409,86 @@ func TestValidateNginxIngressAnnotations(t *testing.T) { }, msg: "invalid nginx.org/rewrite-target annotation, does not start with slash", }, + { + annotations: map[string]string{ + "nginx.org/app-root": "/coffee", + }, + specServices: map[string]bool{}, + isPlus: false, + appProtectEnabled: false, + appProtectDosEnabled: false, + internalRoutesEnabled: false, + expectedErrors: nil, + msg: "valid nginx.org/app-root annotation", + }, + { + annotations: map[string]string{ + "nginx.org/app-root": "/coffee/mocha", + }, + specServices: map[string]bool{}, + isPlus: false, + appProtectEnabled: false, + appProtectDosEnabled: false, + internalRoutesEnabled: false, + expectedErrors: nil, + msg: "valid nginx.org/app-root annotation with nested path", + }, + { + annotations: map[string]string{ + "nginx.org/app-root": "coffee", + }, + specServices: map[string]bool{}, + isPlus: false, + appProtectEnabled: false, + appProtectDosEnabled: false, + internalRoutesEnabled: false, + expectedErrors: []string{ + `annotations.nginx.org/app-root: Invalid value: "coffee": must start with '/'`, + }, + msg: "invalid nginx.org/app-root annotation, does not start with slash", + }, + { + annotations: map[string]string{ + "nginx.org/app-root": "/", + }, + specServices: map[string]bool{}, + isPlus: false, + appProtectEnabled: false, + appProtectDosEnabled: false, + internalRoutesEnabled: false, + expectedErrors: []string{ + `annotations.nginx.org/app-root: Invalid value: "/": cannot be '/'`, + }, + msg: "invalid nginx.org/app-root annotation, cannot be root path", + }, + { + annotations: map[string]string{ + "nginx.org/app-root": "/coffee/", + }, + specServices: map[string]bool{}, + isPlus: false, + appProtectEnabled: false, + appProtectDosEnabled: false, + internalRoutesEnabled: false, + expectedErrors: []string{ + `annotations.nginx.org/app-root: Invalid value: "/coffee/": path should not end with '/'`, + }, + msg: "invalid nginx.org/app-root annotation, cannot end with slash", + }, + { + annotations: map[string]string{ + "nginx.org/app-root": "/tea$1", + }, + specServices: map[string]bool{}, + isPlus: false, + appProtectEnabled: false, + appProtectDosEnabled: false, + internalRoutesEnabled: false, + expectedErrors: []string{ + `annotations.nginx.org/app-root: Invalid value: "/tea$1": contains invalid characters, only alphanumeric, hyphens, underscores, dots, and forward slashes are allowed`, + }, + msg: "invalid nginx.org/app-root annotation, invalid characters", + }, } for _, test := range tests { diff --git a/internal/telemetry/collector_test.go b/internal/telemetry/collector_test.go index d7fcee84f3..93e6be28a5 100644 --- a/internal/telemetry/collector_test.go +++ b/internal/telemetry/collector_test.go @@ -907,6 +907,36 @@ func TestInvalidStandardIngressAnnotations(t *testing.T) { } } +func TestAppRootAnnotationTelemetry(t *testing.T) { + t.Parallel() + + buf := &bytes.Buffer{} + exp := &telemetry.StdoutExporter{Endpoint: buf} + + annotations := map[string]string{ + "nginx.org/app-root": "/coffee", + } + + configurator := newConfiguratorWithIngressWithCustomAnnotations(t, annotations) + + cfg := telemetry.CollectorConfig{ + Configurator: configurator, + K8sClientReader: newTestClientset(node1, kubeNS), + Version: telemetryNICData.ProjectVersion, + } + + c, err := telemetry.NewCollector(cfg, telemetry.WithExporter(exp)) + if err != nil { + t.Fatal(err) + } + c.Collect(context.Background()) + + got := buf.String() + if !strings.Contains(got, "nginx.org/app-root") { + t.Errorf("expected app-root annotation to be collected in telemetry, got: %v", got) + } +} + func TestIngressCountReportsNumberOfDeployedIngresses(t *testing.T) { t.Parallel()