diff --git a/internal/controller/nginx/config/http/config.go b/internal/controller/nginx/config/http/config.go index 2c15e6d6b9..cac1486f3d 100644 --- a/internal/controller/nginx/config/http/config.go +++ b/internal/controller/nginx/config/http/config.go @@ -123,8 +123,11 @@ type Return struct { type SSL struct { Protocols string Ciphers string + ClientCertificate string + VerifyClient string Certificates []string CertificateKeys []string + RequireVerifiedCert bool PreferServerCiphers bool } diff --git a/internal/controller/nginx/config/servers.go b/internal/controller/nginx/config/servers.go index 0570baf6a6..1b3395a059 100644 --- a/internal/controller/nginx/config/servers.go +++ b/internal/controller/nginx/config/servers.go @@ -222,18 +222,29 @@ func buildHTTPSSL(ssl *dataplane.SSL) *http.SSL { certs := make([]string, 0, len(ssl.KeyPairIDs)) keys := make([]string, 0, len(ssl.KeyPairIDs)) + var sslCertificateID string + for _, id := range ssl.KeyPairIDs { pemFile := generatePEMFileName(id) certs = append(certs, pemFile) keys = append(keys, pemFile) } + // ClientCertBundleID can be empty for mode: AllowInsecureFallback + // In this case, we don't want to generate an ID. + if ssl.ClientCertBundleID != "" { + sslCertificateID = generateCertBundleFileName(ssl.ClientCertBundleID) + } + return &http.SSL{ Certificates: certs, CertificateKeys: keys, Protocols: ssl.Protocols, Ciphers: ssl.Ciphers, PreferServerCiphers: ssl.PreferServerCiphers, + ClientCertificate: sslCertificateID, + VerifyClient: string(ssl.VerifyClient), + RequireVerifiedCert: ssl.RequireVerifiedCert, } } diff --git a/internal/controller/nginx/config/servers_template.go b/internal/controller/nginx/config/servers_template.go index 437546c5f5..f40157f32a 100644 --- a/internal/controller/nginx/config/servers_template.go +++ b/internal/controller/nginx/config/servers_template.go @@ -29,6 +29,21 @@ server { {{- if $s.SSL.PreferServerCiphers }} ssl_prefer_server_ciphers on; {{- end }} + {{- if $s.SSL.ClientCertificate }} + ssl_client_certificate {{ $s.SSL.ClientCertificate }}; + {{- end }} + {{- if $s.SSL.VerifyClient }} + ssl_verify_client {{ $s.SSL.VerifyClient }}; + {{- end }} + {{- if $s.SSL.RequireVerifiedCert }} + ssl_verify_depth 4; + error_page 495 496 = @frontend_tls_verify_failed; + {{- end }} + {{- if and $s.SSL $s.SSL.RequireVerifiedCert }} + location @frontend_tls_verify_failed { + return 444; + } + {{- end}} {{- else }} ssl_reject_handshake on; {{- end }} @@ -87,6 +102,21 @@ server { {{- if $s.SSL.PreferServerCiphers }} ssl_prefer_server_ciphers on; {{- end }} + {{- if $s.SSL.ClientCertificate }} + ssl_client_certificate {{ $s.SSL.ClientCertificate }}; + {{- end }} + {{- if $s.SSL.VerifyClient }} + ssl_verify_client {{ $s.SSL.VerifyClient }}; + {{- end }} + {{- if $s.SSL.RequireVerifiedCert }} + ssl_verify_depth 4; + error_page 495 496 = @frontend_tls_verify_failed; + {{- end }} + {{- if and $s.SSL $s.SSL.RequireVerifiedCert }} + location @frontend_tls_verify_failed { + return 444; + } + {{- end }} {{- if $s.MisdirectedRequestVars }} if ({{ $s.MisdirectedRequestVars.SNIVar }} != {{ $s.MisdirectedRequestVars.HostVar }}) { diff --git a/internal/controller/nginx/config/servers_test.go b/internal/controller/nginx/config/servers_test.go index 68ea8eff5d..0ae23098a0 100644 --- a/internal/controller/nginx/config/servers_test.go +++ b/internal/controller/nginx/config/servers_test.go @@ -6785,3 +6785,233 @@ func TestExistingExactPathSet(t *testing.T) { }) } } + +func TestBuildHTTPSSLFrontendTLS(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ssl *dataplane.SSL + expectedClientCertificate string + expectedVerifyClient string + expectedRequireVerified bool + }{ + { + name: "frontend TLS disabled by default", + ssl: &dataplane.SSL{ + KeyPairIDs: []dataplane.SSLKeyPairID{"test-keypair"}, + }, + expectedClientCertificate: "", + expectedVerifyClient: "", + expectedRequireVerified: false, + }, + { + name: "frontend TLS client certificate verification enabled", + ssl: &dataplane.SSL{ + KeyPairIDs: []dataplane.SSLKeyPairID{"test-keypair"}, + ClientCertBundleID: dataplane.CertBundleID("test-ca-bundle"), + VerifyClient: dataplane.SSLVerifyClientOn, + RequireVerifiedCert: true, + }, + expectedClientCertificate: generateCertBundleFileName(dataplane.CertBundleID("test-ca-bundle")), + expectedVerifyClient: string(dataplane.SSLVerifyClientOn), + expectedRequireVerified: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + result := buildHTTPSSL(test.ssl) + + g.Expect(result.ClientCertificate).To(Equal(test.expectedClientCertificate)) + g.Expect(result.VerifyClient).To(Equal(test.expectedVerifyClient)) + g.Expect(result.RequireVerifiedCert).To(Equal(test.expectedRequireVerified)) + g.Expect(result.Certificates).To(Equal([]string{generatePEMFileName(dataplane.SSLKeyPairID("test-keypair"))})) + g.Expect(result.CertificateKeys).To(Equal([]string{generatePEMFileName(dataplane.SSLKeyPairID("test-keypair"))})) + }) + } +} + +func TestExecuteServers_FrontendTLS(t *testing.T) { + t.Parallel() + + tests := []struct { + ssl *dataplane.SSL + name string + expectedPresent []string + expectedAbsent []string + isDefault bool + }{ + { + name: "frontend TLS disabled", + isDefault: false, + ssl: &dataplane.SSL{ + KeyPairIDs: []dataplane.SSLKeyPairID{"test-keypair"}, + }, + expectedPresent: []string{ + "ssl_certificate /etc/nginx/secrets/test-keypair.pem;", + "ssl_certificate_key /etc/nginx/secrets/test-keypair.pem;", + }, + expectedAbsent: []string{ + "ssl_client_certificate ", + "ssl_verify_client ", + "ssl_verify_depth ", + "error_page 495 496 = @frontend_tls_verify_failed;", + "location @frontend_tls_verify_failed {", + "return 444;", + }, + }, + { + name: "frontend TLS enabled", + isDefault: false, + ssl: &dataplane.SSL{ + KeyPairIDs: []dataplane.SSLKeyPairID{"test-keypair"}, + ClientCertBundleID: dataplane.CertBundleID("test-ca-bundle"), + VerifyClient: dataplane.SSLVerifyClientOn, + RequireVerifiedCert: true, + }, + expectedPresent: []string{ + "ssl_certificate /etc/nginx/secrets/test-keypair.pem;", + "ssl_certificate_key /etc/nginx/secrets/test-keypair.pem;", + "ssl_client_certificate " + generateCertBundleFileName(dataplane.CertBundleID("test-ca-bundle")) + ";", + "ssl_verify_client on;", + "ssl_verify_depth ", + "error_page 495 496 = @frontend_tls_verify_failed;", + "location @frontend_tls_verify_failed {", + "return 444;", + }, + }, + { + name: "frontend TLS enabled, with mode: AllowInsecureFallback", + isDefault: false, + ssl: &dataplane.SSL{ + KeyPairIDs: []dataplane.SSLKeyPairID{"test-keypair"}, + VerifyClient: dataplane.SSLVerifyClientOptionalNoCA, + }, + expectedPresent: []string{ + "ssl_certificate /etc/nginx/secrets/test-keypair.pem;", + "ssl_certificate_key /etc/nginx/secrets/test-keypair.pem;", + "ssl_verify_client optional_no_ca;", + }, + expectedAbsent: []string{ + "ssl_verify_depth ", + "ssl_client_certificate ", + "error_page 495 496 = @frontend_tls_verify_failed;", + "location @frontend_tls_verify_failed {", + "return 444;", + }, + }, + { + name: "default SSL server without frontend TLS", + isDefault: true, + ssl: &dataplane.SSL{ + KeyPairIDs: []dataplane.SSLKeyPairID{"test-keypair"}, + }, + expectedPresent: []string{ + "listen 8443 ssl default_server;", + "ssl_certificate /etc/nginx/secrets/test-keypair.pem;", + "ssl_certificate_key /etc/nginx/secrets/test-keypair.pem;", + }, + expectedAbsent: []string{ + "ssl_client_certificate ", + "ssl_verify_client ", + "ssl_verify_depth ", + "error_page 495 496 = @frontend_tls_verify_failed;", + "location @frontend_tls_verify_failed {", + "return 444;", + }, + }, + { + name: "default SSL server with frontend TLS enabled", + isDefault: true, + ssl: &dataplane.SSL{ + KeyPairIDs: []dataplane.SSLKeyPairID{"test-keypair"}, + ClientCertBundleID: dataplane.CertBundleID("test-ca-bundle"), + VerifyClient: dataplane.SSLVerifyClientOn, + RequireVerifiedCert: true, + }, + expectedPresent: []string{ + "listen 8443 ssl default_server;", + "ssl_certificate /etc/nginx/secrets/test-keypair.pem;", + "ssl_certificate_key /etc/nginx/secrets/test-keypair.pem;", + "ssl_client_certificate " + generateCertBundleFileName(dataplane.CertBundleID("test-ca-bundle")) + ";", + "ssl_verify_client on;", + "ssl_verify_depth ", + "error_page 495 496 = @frontend_tls_verify_failed;", + "location @frontend_tls_verify_failed {", + "return 444;", + }, + expectedAbsent: []string{}, + }, + { + name: "default SSL server with frontend TLS in AllowInsecureFallback mode", + isDefault: true, + ssl: &dataplane.SSL{ + KeyPairIDs: []dataplane.SSLKeyPairID{"test-keypair"}, + VerifyClient: dataplane.SSLVerifyClientOptionalNoCA, + }, + expectedPresent: []string{ + "listen 8443 ssl default_server;", + "ssl_certificate /etc/nginx/secrets/test-keypair.pem;", + "ssl_certificate_key /etc/nginx/secrets/test-keypair.pem;", + "ssl_verify_client optional_no_ca;", + }, + expectedAbsent: []string{ + "ssl_client_certificate ", + "ssl_verify_depth ", + "error_page 495 496 = @frontend_tls_verify_failed;", + "location @frontend_tls_verify_failed {", + "return 444;", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + var server dataplane.VirtualServer + if test.isDefault { + server = dataplane.VirtualServer{ + IsDefault: true, + Port: 8443, + SSL: test.ssl, + } + } else { + server = dataplane.VirtualServer{ + Hostname: "example.com", + Port: 8443, + SSL: test.ssl, + } + } + + conf := dataplane.Configuration{ + SSLServers: []dataplane.VirtualServer{server}, + } + + gen := GeneratorImpl{} + results := gen.executeServers(conf, &policiesfakes.FakeGenerator{}, alwaysFalseKeepAliveChecker) + + var httpData string + for _, res := range results { + if res.dest == httpConfigFile { + httpData = string(res.data) + break + } + } + + g.Expect(httpData).NotTo(BeEmpty()) + + for _, sub := range test.expectedPresent { + g.Expect(httpData).To(ContainSubstring(sub)) + } + for _, sub := range test.expectedAbsent { + g.Expect(httpData).NotTo(ContainSubstring(sub)) + } + }) + } +} diff --git a/internal/controller/state/conditions/conditions.go b/internal/controller/state/conditions/conditions.go index 559a1c720c..e151ad40f8 100644 --- a/internal/controller/state/conditions/conditions.go +++ b/internal/controller/state/conditions/conditions.go @@ -571,8 +571,8 @@ func NewRouteResolvedRefsInvalidFilter(msg string) Condition { // the NoConflicts condition is excluded to avoid conflicting condition states. func NewDefaultListenerConditions(existingConditions []Condition) []Condition { defaultConds := []Condition{ - NewListenerAccepted(), NewListenerProgrammed(), + NewListenerAccepted(), } // Only add ResolvedRefs=true if there are no existing ResolvedRefs conditions @@ -588,7 +588,7 @@ func NewDefaultListenerConditions(existingConditions []Condition) []Condition { return defaultConds } -// hasResolvedRefsConditions checks if there are any existing ResolvedRefs conditions. +// hasResolvedRefsConditions checks if the Listener has any ResolvedRefs=False conditions. func hasResolvedRefsConditions(conditions []Condition) bool { for _, cond := range conditions { if cond.Type == string(v1.ListenerConditionResolvedRefs) { @@ -1005,6 +1005,54 @@ func NewGatewayNotProgrammedInvalid(msg string) Condition { } } +// NewGatewayInsecureFrontendValidationMode returns a Condition that indicates +// the Gateway is accepted, but is using an insecure frontend validation mode. +func NewGatewayInsecureFrontendValidationMode(msg string) Condition { + return Condition{ + Type: string(v1.GatewayConditionInsecureFrontendValidationMode), + Status: metav1.ConditionTrue, + Reason: string(v1.GatewayReasonConfigurationChanged), + Message: msg, + } +} + +// NewListenerInvalidCaCertificateRef returns a Condition indicating +// that a CA CertificateRef for a Listener is invalid. +func NewListenerInvalidCaCertificateRef(msg string) Condition { + return Condition{ + Type: string(v1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + Reason: string(v1.ListenerReasonInvalidCACertificateRef), + Message: msg, + } +} + +// NewListenerInvalidCaCertificateKind returns a Condition indicating +// that a CA CertificateRef Kind for a Listener is invalid. +func NewListenerInvalidCaCertificateKind(msg string) Condition { + return Condition{ + Type: string(v1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + Reason: string(v1.ListenerReasonInvalidCACertificateKind), + Message: msg, + } +} + +// NewListenerInvalidNoValidCACertificate returns Conditions indicating +// that all CA Certificates for a Listener are invalid. +// It marks the listener as not Accepted and not Programmed. +func NewListenerInvalidNoValidCACertificate(msg string) []Condition { + return []Condition{ + { + Type: string(v1.ListenerConditionAccepted), + Status: metav1.ConditionFalse, + Reason: string(v1.ListenerReasonNoValidCACertificate), + Message: msg, + }, + NewListenerNotProgrammedInvalid(msg), + } +} + // NewNginxGatewayValid returns a Condition that indicates that the NginxGateway config is valid. func NewNginxGatewayValid() Condition { return Condition{ diff --git a/internal/controller/state/conditions/conditions_test.go b/internal/controller/state/conditions/conditions_test.go index 5d696a8fe0..69078101d9 100644 --- a/internal/controller/state/conditions/conditions_test.go +++ b/internal/controller/state/conditions/conditions_test.go @@ -153,26 +153,56 @@ func TestNewDefaultListenerConditions(t *testing.T) { tests := []struct { name string existingConditions []Condition + expectAccepted bool expectResolvedRefs bool + expectNoConflicts bool }{ { - name: "no existing conditions includes ResolvedRefs", + name: "no existing conditions includes all defaults", existingConditions: nil, + expectAccepted: true, expectResolvedRefs: true, + expectNoConflicts: true, }, { - name: "existing ResolvedRefs condition (InvalidCertificateRef) is preserved", + name: "existing ResolvedRefs=False (InvalidCertificateRef) suppresses default ResolvedRefs", existingConditions: []Condition{ NewListenerUnresolvedCertificateRef("some cert ref error", string(v1.ListenerReasonInvalidCertificateRef)), }, + expectAccepted: true, expectResolvedRefs: false, + expectNoConflicts: true, }, { - name: "existing ResolvedRefs condition (RefNotPermitted) is preserved", + name: "existing ResolvedRefs=False (RefNotPermitted) suppresses default ResolvedRefs", existingConditions: []Condition{ NewListenerUnresolvedCertificateRef("some ref not permitted error", string(v1.ListenerReasonRefNotPermitted)), }, + expectAccepted: true, expectResolvedRefs: false, + expectNoConflicts: true, + }, + { + name: "existing Conflicted condition suppresses default NoConflicts", + existingConditions: []Condition{ + { + Type: string(v1.ListenerConditionConflicted), + Status: metav1.ConditionTrue, + Reason: "SomeConflict", + }, + }, + expectAccepted: true, + expectResolvedRefs: true, + expectNoConflicts: false, + }, + { + name: "existing OverlappingTLSConfig condition suppresses default NoConflicts", + existingConditions: []Condition{ + NewListenerOverlappingTLSConfig(v1.ListenerReasonHostnameConflict, "overlapping TLS"), + }, + expectAccepted: true, + expectResolvedRefs: true, + expectNoConflicts: false, }, } @@ -183,13 +213,91 @@ func TestNewDefaultListenerConditions(t *testing.T) { result := NewDefaultListenerConditions(test.existingConditions) + hasAccepted := false hasResolvedRefs := false + hasNoConflicts := false for _, c := range result { - if c.Type == string(v1.ListenerConditionResolvedRefs) { + switch c.Type { + case string(v1.ListenerConditionAccepted): + hasAccepted = true + case string(v1.ListenerConditionResolvedRefs): hasResolvedRefs = true + case string(v1.ListenerConditionConflicted): + hasNoConflicts = true } } + g.Expect(hasAccepted).To(Equal(test.expectAccepted)) g.Expect(hasResolvedRefs).To(Equal(test.expectResolvedRefs)) + g.Expect(hasNoConflicts).To(Equal(test.expectNoConflicts)) + }) + } +} + +func TestNewListenerCACertificateConditions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + newConds func() []Condition + expected []Condition + }{ + { + name: "NewListenerInvalidCaCertificateRef", + newConds: func() []Condition { + return []Condition{NewListenerInvalidCaCertificateRef("invalid CA cert ref")} + }, + expected: []Condition{ + { + Type: string(v1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + Reason: string(v1.ListenerReasonInvalidCACertificateRef), + Message: "invalid CA cert ref", + }, + }, + }, + { + name: "NewListenerInvalidCaCertificateKind", + newConds: func() []Condition { + return []Condition{NewListenerInvalidCaCertificateKind("invalid CA cert kind")} + }, + expected: []Condition{ + { + Type: string(v1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + Reason: string(v1.ListenerReasonInvalidCACertificateKind), + Message: "invalid CA cert kind", + }, + }, + }, + { + name: "NewListenerInvalidNoValidCACertificate", + newConds: func() []Condition { + return NewListenerInvalidNoValidCACertificate("all CA certs invalid") + }, + expected: []Condition{ + { + Type: string(v1.ListenerConditionAccepted), + Status: metav1.ConditionFalse, + Reason: string(v1.ListenerReasonNoValidCACertificate), + Message: "all CA certs invalid", + }, + { + Type: string(v1.ListenerConditionProgrammed), + Status: metav1.ConditionFalse, + Reason: string(v1.ListenerReasonInvalid), + Message: "all CA certs invalid", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + conds := test.newConds() + g.Expect(conds).To(Equal(test.expected)) }) } } diff --git a/internal/controller/state/dataplane/configuration.go b/internal/controller/state/dataplane/configuration.go index 0620a9a198..3be14a9e8d 100644 --- a/internal/controller/state/dataplane/configuration.go +++ b/internal/controller/state/dataplane/configuration.go @@ -101,11 +101,18 @@ func BuildConfiguration( nginxPlus = buildNginxPlus(gateway) } + refCertBundles := buildRefCertificateBundles(g.ReferencedSecrets, g.ReferencedCaCertConfigMaps) + certBundles := buildCertBundles( - buildRefCertificateBundles(g.ReferencedSecrets, g.ReferencedCaCertConfigMaps), + refCertBundles, backendGroups, authCertBundles, ) + maps.Copy(certBundles, buildFrontendTLSCertBundles( + gateway, + sslServers, + refCertBundles, + )) config := Configuration{ HTTPServers: httpServers, @@ -437,6 +444,164 @@ func buildJWTRemoteTLSCABundles( return bundles } +// listenerClientSettings captures the information about a listener +// for configuring SSL servers with client verification settings. +type listenerClientSettings struct { + CertBundleID CertBundleID + validationMode v1.FrontendValidationModeType +} + +func buildFrontendTLSCertBundles( + gateway *graph.Gateway, + sslServers []VirtualServer, + refCertBundles []secrets.CertificateBundle, +) map[CertBundleID]CertBundle { + bundles := make(map[CertBundleID]CertBundle, len(refCertBundles)) + clientSettingsMap := make(map[int32]listenerClientSettings) + + if !gateway.Valid || gateway.Source.Spec.TLS == nil || gateway.Source.Spec.TLS.Frontend == nil { + return bundles + } + + refCertBundleIndex := indexRefCertBundles(refCertBundles) + + for _, listener := range gateway.Listeners { + if listener.Source.Protocol != v1.HTTPSProtocolType { + continue + } + + if len(listener.CACertificateRefs) == 0 { + continue + } + // Create a unique cert bundle ID for this listener gateway combo. + // e.g. cert_bundle_default_gateway_443_https + // for a listener on port 443 named "https" on a gateway in the default namespace. + caCertRef := types.NamespacedName{ + Namespace: gateway.Source.Namespace, + Name: fmt.Sprintf("%s_%d_%s", + gateway.Source.Name, + listener.Source.Port, + listener.Name, + ), + } + id := generateCertBundleID(caCertRef) + // We map listener port to the CertBundleID and ValidationMode of this listener + // to later configure the relevant SSL Servers with this data. + // This avoids iterating over each SSL Server for each Listener. + clientSettingsMap[listener.Source.Port] = listenerClientSettings{ + CertBundleID: id, + validationMode: listener.ValidationMode, + } + // If the validation mode is AllowInsecureFallback + // we do not want to configure any CA bundles for this listener. + if listener.ValidationMode != v1.AllowInsecureFallback { + bundles = getFrontendTLSCertBundles( + id, + bundles, + gateway, + refCertBundleIndex, + listener.CACertificateRefs, + ) + } + } + addClientSettingsToSSLServers(sslServers, clientSettingsMap) + return bundles +} + +// refCertBundleKey is used as the key for indexing referenced certificate bundles +// when building frontend TLS cert bundles. +// It consists of the kind, namespace, and name of the referenced certificate bundle. +type refCertBundleKey struct { + kind v1.Kind + namespace v1.Namespace + name v1.ObjectName +} + +// indexRefCertBundles creates an index of the referenced certificate bundles +// based on their kind, namespace, and name for faster lookup when building frontend TLS cert bundles. +func indexRefCertBundles( + refCertBundles []secrets.CertificateBundle, +) map[refCertBundleKey]secrets.CertificateBundle { + index := make(map[refCertBundleKey]secrets.CertificateBundle, len(refCertBundles)) + for _, bundle := range refCertBundles { + key := refCertBundleKey{ + kind: bundle.Kind, + namespace: v1.Namespace(bundle.Name.Namespace), + name: v1.ObjectName(bundle.Name.Name), + } + index[key] = bundle + } + return index +} + +func getFrontendTLSCertBundles( + id CertBundleID, + bundles map[CertBundleID]CertBundle, + gateway *graph.Gateway, + refCertBundleIndex map[refCertBundleKey]secrets.CertificateBundle, + listenerCACertRefs []v1.ObjectReference, +) map[CertBundleID]CertBundle { + certBundles := make([]CertBundle, 0, len(listenerCACertRefs)) + for _, ref := range listenerCACertRefs { + if ref.Name == "" { + continue + } + refNamespace := v1.Namespace(gateway.Source.Namespace) + if ref.Namespace != nil { + refNamespace = *ref.Namespace + } + + key := refCertBundleKey{ + kind: ref.Kind, + namespace: refNamespace, + name: ref.Name, + } + if bundle, exists := refCertBundleIndex[key]; exists { + certRefData := getCertRefBundleData(bundle) + certBundles = append(certBundles, certRefData) + } + } + if len(certBundles) == 0 { + return bundles + } + + if _, exists := bundles[id]; !exists { + for _, v := range certBundles { + bundles[id] = append(bundles[id], v...) + } + } + + return bundles +} + +// addClientSettingsToSSLServers modifies existing SSL servers to assign +// client certificate verification settings based on the listener's validation mode and CA cert refs. +func addClientSettingsToSSLServers( + sslServers []VirtualServer, + clientSettingsMap map[int32]listenerClientSettings, +) { + for i := range sslServers { + if sslServers[i].SSL == nil { + continue + } + if clientSettings, exists := clientSettingsMap[sslServers[i].Port]; exists { + switch clientSettings.validationMode { + case v1.AllowInsecureFallback: + // Request client certificate but allow any certificate (valid, invalid, or none) + // Do not configure CA bundle verification for this mode + sslServers[i].SSL.ClientCertBundleID = "" + sslServers[i].SSL.VerifyClient = SSLVerifyClientOptionalNoCA + sslServers[i].SSL.RequireVerifiedCert = false + default: + // AllowValidOnly is default when no validation mode is specified. + sslServers[i].SSL.ClientCertBundleID = clientSettings.CertBundleID + sslServers[i].SSL.VerifyClient = SSLVerifyClientOn + sslServers[i].SSL.RequireVerifiedCert = true + } + } + } +} + func buildRefCertificateBundles( secretsMap map[types.NamespacedName]*secrets.Secret, configMaps map[types.NamespacedName]*configmaps.CaCertConfigMap, @@ -490,19 +655,24 @@ func buildCertBundles( for _, bundle := range refCertBundles { id := generateCertBundleID(bundle.Name) if _, exists := referencedByBackendGroup[id]; exists { - // the cert could be base64 encoded or plaintext - data := make([]byte, base64.StdEncoding.DecodedLen(len(bundle.Cert.CACert))) - _, err := base64.StdEncoding.Decode(data, bundle.Cert.CACert) - if err != nil { - data = bundle.Cert.CACert - } - bundles[id] = data + bundles[id] = getCertRefBundleData(bundle) } } - return bundles } +func getCertRefBundleData(bundle secrets.CertificateBundle) []byte { + // the cert could be base64 encoded or plaintext + data := make([]byte, base64.StdEncoding.DecodedLen(len(bundle.Cert.CACert))) + n, err := base64.StdEncoding.Decode(data, bundle.Cert.CACert) + if err != nil { + data = bundle.Cert.CACert + } else { + data = data[:n] + } + return data +} + func buildAuthSecrets( authenticationFilters map[types.NamespacedName]*graph.AuthenticationFilter, secretsMap map[types.NamespacedName]*secrets.Secret, diff --git a/internal/controller/state/dataplane/configuration_test.go b/internal/controller/state/dataplane/configuration_test.go index 63169cb6e6..4051fbbdfa 100644 --- a/internal/controller/state/dataplane/configuration_test.go +++ b/internal/controller/state/dataplane/configuration_test.go @@ -2,6 +2,7 @@ package dataplane import ( "context" + "encoding/base64" "errors" "fmt" "sort" @@ -8106,3 +8107,848 @@ func TestBuildJWTRemoteTLSCABundles(t *testing.T) { }) } } + +func buildFrontendTLSRefCertBundles( + secretsMap map[types.NamespacedName]*secrets.Secret, + caCertConfigMaps map[types.NamespacedName]*configmaps.CaCertConfigMap, +) []secrets.CertificateBundle { + bundles := make([]secrets.CertificateBundle, 0) + + for nsName, secret := range secretsMap { + if secret == nil || secret.Source == nil { + continue + } + + caData, exists := secret.Source.Data[secrets.CAKey] + if !exists { + continue + } + + bundles = append(bundles, *secrets.NewCertificateBundle( + nsName, + kinds.Secret, + &secrets.Certificate{CACert: caData}, + )) + } + + for nsName, cm := range caCertConfigMaps { + if cm == nil || cm.Source == nil { + continue + } + + cert := &secrets.Certificate{} + hasData := false + + if cm.Source.Data != nil { + if data, exists := cm.Source.Data[secrets.CAKey]; exists { + cert.CACert = []byte(data) + hasData = true + } + } + + if cm.Source.BinaryData != nil { + if data, exists := cm.Source.BinaryData[secrets.CAKey]; exists { + cert.CACert = data + hasData = true + } + } + + if !hasData { + continue + } + + bundles = append(bundles, *secrets.NewCertificateBundle( + nsName, + kinds.ConfigMap, + cert, + )) + } + + return bundles +} + +func buildFrontendTLSGateway(listeners []*graph.Listener) *graph.Gateway { + return &graph.Gateway{ + Valid: true, + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "gateway-ns", Name: "test-gateway"}, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + CACertificateRefs: []v1.ObjectReference{ + {Name: v1.ObjectName("default-ca")}, + }, + }, + }, + }, + }, + }, + }, + Listeners: listeners, + } +} + +func TestGetFrontendTLSCertBundleData(t *testing.T) { + t.Parallel() + + gatewayNs := "gateway-ns" + + gateway := &graph.Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: gatewayNs}, + }, + } + + encodedCMData := base64.StdEncoding.EncodeToString([]byte("cm-base64-ca")) + + tests := []struct { + ref v1.ObjectReference + secretsMap map[types.NamespacedName]*secrets.Secret + caCertConfigMaps map[types.NamespacedName]*configmaps.CaCertConfigMap + name string + expected CertBundle + }{ + { + name: "Empty ref name", + ref: v1.ObjectReference{}, + expected: nil, + }, + { + name: "CA bundle from secret", + ref: v1.ObjectReference{ + Name: v1.ObjectName("frontend-ca-secret"), + Kind: v1.Kind(kinds.Secret), + Namespace: helpers.GetPointer(v1.Namespace(gatewayNs)), + }, + secretsMap: map[types.NamespacedName]*secrets.Secret{ + {Namespace: gatewayNs, Name: "frontend-ca-secret"}: { + Source: &apiv1.Secret{ + Data: map[string][]byte{ + secrets.CAKey: []byte("secret-ca"), + }, + }, + }, + }, + expected: []byte("secret-ca"), + }, + { + name: "CA bundle from configmap plaintext", + ref: v1.ObjectReference{ + Name: v1.ObjectName("frontend-ca-cm-plain"), + Kind: v1.Kind(kinds.ConfigMap), + Namespace: helpers.GetPointer(v1.Namespace(gatewayNs)), + }, + caCertConfigMaps: map[types.NamespacedName]*configmaps.CaCertConfigMap{ + {Namespace: gatewayNs, Name: "frontend-ca-cm-plain"}: { + Source: &apiv1.ConfigMap{ + Data: map[string]string{ + secrets.CAKey: "cm-plain-ca", + }, + }, + }, + }, + expected: []byte("cm-plain-ca"), + }, + { + name: "CA bundle from configmap base64 data", + ref: v1.ObjectReference{ + Name: v1.ObjectName("frontend-ca-cm-b64"), + Kind: v1.Kind(kinds.ConfigMap), + Namespace: helpers.GetPointer(v1.Namespace(gatewayNs)), + }, + caCertConfigMaps: map[types.NamespacedName]*configmaps.CaCertConfigMap{ + {Namespace: gatewayNs, Name: "frontend-ca-cm-b64"}: { + Source: &apiv1.ConfigMap{ + Data: map[string]string{ + secrets.CAKey: encodedCMData, + }, + }, + }, + }, + expected: []byte("cm-base64-ca"), + }, + { + name: "ConfigMap binary data takes precedence", + ref: v1.ObjectReference{ + Name: v1.ObjectName("frontend-ca-cm-bin"), + Kind: v1.Kind(kinds.ConfigMap), + Namespace: helpers.GetPointer(v1.Namespace(gatewayNs)), + }, + caCertConfigMaps: map[types.NamespacedName]*configmaps.CaCertConfigMap{ + {Namespace: gatewayNs, Name: "frontend-ca-cm-bin"}: { + Source: &apiv1.ConfigMap{ + Data: map[string]string{ + secrets.CAKey: "cm-plain-ca", + }, + BinaryData: map[string][]byte{ + secrets.CAKey: []byte("cm-binary-ca"), + }, + }, + }, + }, + expected: []byte("cm-binary-ca"), + }, + { + name: "Secret kind chooses secret data when both resources exist", + ref: v1.ObjectReference{ + Name: v1.ObjectName("frontend-ca-shared"), + Kind: v1.Kind(kinds.Secret), + Namespace: helpers.GetPointer(v1.Namespace(gatewayNs)), + }, + secretsMap: map[types.NamespacedName]*secrets.Secret{ + {Namespace: gatewayNs, Name: "frontend-ca-shared"}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("secret-kind-data")}}, + }, + }, + caCertConfigMaps: map[types.NamespacedName]*configmaps.CaCertConfigMap{ + {Namespace: gatewayNs, Name: "frontend-ca-shared"}: { + Source: &apiv1.ConfigMap{Data: map[string]string{secrets.CAKey: "configmap-kind-data"}}, + }, + }, + expected: []byte("secret-kind-data"), + }, + { + name: "ConfigMap kind chooses configmap data when both resources exist", + ref: v1.ObjectReference{ + Name: v1.ObjectName("frontend-ca-shared"), + Kind: v1.Kind(kinds.ConfigMap), + Namespace: helpers.GetPointer(v1.Namespace(gatewayNs)), + }, + secretsMap: map[types.NamespacedName]*secrets.Secret{ + {Namespace: gatewayNs, Name: "frontend-ca-shared"}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("secret-kind-data")}}, + }, + }, + caCertConfigMaps: map[types.NamespacedName]*configmaps.CaCertConfigMap{ + {Namespace: gatewayNs, Name: "frontend-ca-shared"}: { + Source: &apiv1.ConfigMap{Data: map[string]string{secrets.CAKey: "configmap-kind-data"}}, + }, + }, + expected: []byte("configmap-kind-data"), + }, + { + name: "Refs with the same name in different namespaces; choose the one in the ref namespace", + ref: v1.ObjectReference{ + Name: v1.ObjectName("frontend-ca-shared"), + Kind: v1.Kind(kinds.Secret), + Namespace: helpers.GetPointer(v1.Namespace("other-ns")), + }, + secretsMap: map[types.NamespacedName]*secrets.Secret{ + {Namespace: gatewayNs, Name: "frontend-ca-shared"}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("gateway-ns-data")}}, + }, + {Namespace: "other-ns", Name: "frontend-ca-shared"}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("other-ns-data")}}, + }, + }, + expected: []byte("other-ns-data"), + }, + { + name: "Ref with nil namespace, gateway with non-nil namespace", + ref: v1.ObjectReference{ + Name: v1.ObjectName("frontend-ca-secret"), + Kind: v1.Kind(kinds.Secret), + Namespace: nil, + }, + secretsMap: map[types.NamespacedName]*secrets.Secret{ + {Namespace: gatewayNs, Name: "frontend-ca-secret"}: { + Source: &apiv1.Secret{ + Data: map[string][]byte{ + secrets.CAKey: []byte("secret-ca"), + }, + }, + }, + }, + expected: []byte("secret-ca"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + bundles := make(map[CertBundleID]CertBundle) + refCertBundles := buildFrontendTLSRefCertBundles(test.secretsMap, test.caCertConfigMaps) + refCertBundleIndex := indexRefCertBundles(refCertBundles) + refs := []v1.ObjectReference{test.ref} + bundleID := CertBundleID("cert_bundle_test_listener") + + result := getFrontendTLSCertBundles(bundleID, bundles, gateway, refCertBundleIndex, refs) + + g.Expect(result[bundleID]).To(Equal(test.expected)) + }) + } +} + +func TestBuildFrontendTLSCertBundles(t *testing.T) { + t.Parallel() + + gatewayNs := "gateway-ns" + gatewayName := "test-gateway" + caRefFormat := "%s_%d_%s" + caRefName1 := "frontend-ca" + caRefSecret1 := v1.ObjectReference{ + Name: v1.ObjectName(caRefName1), + Kind: v1.Kind(kinds.Secret), + Namespace: helpers.GetPointer(v1.Namespace(gatewayNs)), + } + caRefName2 := "frontend-ca-2" + caRefSecret2 := v1.ObjectReference{ + Name: v1.ObjectName(caRefName2), + Kind: v1.Kind(kinds.Secret), + Namespace: helpers.GetPointer(v1.Namespace(gatewayNs)), + } + caConfigMapRef := v1.ObjectReference{ + Name: v1.ObjectName(caRefName1), + Kind: v1.Kind(kinds.ConfigMap), + Namespace: helpers.GetPointer(v1.Namespace(gatewayNs)), + } + + type expectedServerConfig struct { + expectedBundleID CertBundleID + expectedVerifyClientMode SSLVerifyClientMode + expectedRequireVerifiedCert bool + } + + tests := []struct { + secretsMap map[types.NamespacedName]*secrets.Secret + caCertConfigMaps map[types.NamespacedName]*configmaps.CaCertConfigMap + gateway *graph.Gateway + expectedSSLServerConfigs map[int32]expectedServerConfig + name string + expectedBundleID CertBundleID + expectedServerBundle CertBundleID + expectedBundleData CertBundle + sslServers []VirtualServer + expectBundle bool + }{ + { + name: "Listener-resolved frontend CA refs from secret configure ssl servers", + gateway: buildFrontendTLSGateway([]*graph.Listener{{ + Name: "https-listener", + Valid: true, + ValidationMode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{caRefSecret1}, + Source: v1.Listener{ + Protocol: v1.HTTPSProtocolType, + Port: 443, + }, + }}), + sslServers: []VirtualServer{ + {Port: 443, SSL: &SSL{}}, + }, + secretsMap: map[types.NamespacedName]*secrets.Secret{ + {Namespace: gatewayNs, Name: caRefName1}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("frontend-ca-data")}}, + }, + }, + expectedBundleID: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectedBundleData: []byte("frontend-ca-data"), + expectedServerBundle: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectBundle: true, + expectedSSLServerConfigs: map[int32]expectedServerConfig{ + 443: { + expectedBundleID: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectedVerifyClientMode: SSLVerifyClientOn, + expectedRequireVerifiedCert: true, + }, + }, + }, + { + name: "AllowInsecureFallback disables verified cert requirement", + gateway: buildFrontendTLSGateway([]*graph.Listener{{ + Name: "https-listener", + Valid: true, + ValidationMode: v1.AllowInsecureFallback, + CACertificateRefs: []v1.ObjectReference{caRefSecret1}, + Source: v1.Listener{ + Protocol: v1.HTTPSProtocolType, + Port: 443, + }, + }}), + sslServers: []VirtualServer{{Port: 443, SSL: &SSL{}}}, + secretsMap: map[types.NamespacedName]*secrets.Secret{ + {Namespace: gatewayNs, Name: caRefName1}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("frontend-ca-data")}}, + }, + }, + expectedBundleID: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectedBundleData: []byte("frontend-ca-data"), + expectedServerBundle: "", + expectBundle: false, + expectedSSLServerConfigs: map[int32]expectedServerConfig{ + 443: { + expectedBundleID: "", + expectedVerifyClientMode: SSLVerifyClientOptionalNoCA, + expectedRequireVerifiedCert: false, + }, + }, + }, + { + name: "HTTPS listener with no resolved CA refs produces no bundle and no SSL mutation", + gateway: buildFrontendTLSGateway([]*graph.Listener{{ + Name: "https-listener", + Valid: true, + ValidationMode: v1.AllowValidOnly, + Source: v1.Listener{ + Protocol: v1.HTTPSProtocolType, + Port: 443, + }, + }}), + sslServers: []VirtualServer{{ + Port: 443, + SSL: &SSL{ + ClientCertBundleID: "existing-bundle", + VerifyClient: "on", + RequireVerifiedCert: true, + }, + }}, + expectedServerBundle: "existing-bundle", + expectBundle: false, + expectedSSLServerConfigs: map[int32]expectedServerConfig{ + 443: { + expectedBundleID: "existing-bundle", + expectedVerifyClientMode: SSLVerifyClientOn, + expectedRequireVerifiedCert: true, + }, + }, + }, + { + name: "Multiple listener CA refs are concatenated in a single bundle", + gateway: buildFrontendTLSGateway([]*graph.Listener{{ + Name: "https-listener", + Valid: true, + ValidationMode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{caRefSecret1, caRefSecret2}, + Source: v1.Listener{ + Protocol: v1.HTTPSProtocolType, + Port: 443, + }, + }}), + sslServers: []VirtualServer{{Port: 443, SSL: &SSL{}}}, + secretsMap: map[types.NamespacedName]*secrets.Secret{ + {Namespace: gatewayNs, Name: caRefName1}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("frontend-ca-data")}}, + }, + {Namespace: gatewayNs, Name: caRefName2}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("frontend-ca-data-2")}}, + }, + }, + expectedBundleID: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectedBundleData: []byte("frontend-ca-datafrontend-ca-data-2"), + expectedServerBundle: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectBundle: true, + expectedSSLServerConfigs: map[int32]expectedServerConfig{ + 443: { + expectedBundleID: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectedVerifyClientMode: SSLVerifyClientOn, + expectedRequireVerifiedCert: true, + }, + }, + }, + { + name: "ConfigMap CA ref produces bundle and SSL client config", + gateway: buildFrontendTLSGateway([]*graph.Listener{{ + Name: "https-listener", + Valid: true, + ValidationMode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{caConfigMapRef}, + Source: v1.Listener{ + Protocol: v1.HTTPSProtocolType, + Port: 443, + }, + }}), + sslServers: []VirtualServer{{Port: 443, SSL: &SSL{}}}, + caCertConfigMaps: map[types.NamespacedName]*configmaps.CaCertConfigMap{ + {Namespace: gatewayNs, Name: caRefName1}: { + Source: &apiv1.ConfigMap{ + Data: map[string]string{ + secrets.CAKey: "configmap-ca-data", + }, + }, + }, + }, + expectedBundleID: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectedBundleData: []byte("configmap-ca-data"), + expectedServerBundle: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectBundle: true, + expectedSSLServerConfigs: map[int32]expectedServerConfig{ + 443: { + expectedBundleID: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectedVerifyClientMode: SSLVerifyClientOn, + expectedRequireVerifiedCert: true, + }, + }, + }, + { + name: "Two HTTPS listeners with different CA refs", + gateway: buildFrontendTLSGateway([]*graph.Listener{ + { + Name: "https-listener", + Valid: true, + ValidationMode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{caRefSecret1}, + Source: v1.Listener{ + Protocol: v1.HTTPSProtocolType, + Port: 443, + }, + }, + { + Name: "https-listener-2", + Valid: true, + ValidationMode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{caRefSecret2}, + Source: v1.Listener{ + Protocol: v1.HTTPSProtocolType, + Port: 8443, + }, + }, + }), + sslServers: []VirtualServer{ + {Port: 443, SSL: &SSL{}}, + {Port: 8443, SSL: &SSL{}}, + }, + secretsMap: map[types.NamespacedName]*secrets.Secret{ + {Namespace: gatewayNs, Name: caRefName1}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("frontend-ca-data")}}, + }, + {Namespace: gatewayNs, Name: caRefName2}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("frontend-ca-data-2")}}, + }, + }, + expectedBundleID: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectedBundleData: []byte("frontend-ca-data"), + expectedServerBundle: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectBundle: true, + expectedSSLServerConfigs: map[int32]expectedServerConfig{ + 443: { + expectedBundleID: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectedVerifyClientMode: SSLVerifyClientOn, + expectedRequireVerifiedCert: true, + }, + 8443: { + expectedBundleID: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 8443, "https-listener-2"), + }), + expectedVerifyClientMode: SSLVerifyClientOn, + expectedRequireVerifiedCert: true, + }, + }, + }, + { + name: "Two HTTPS listeners with different validation modes", + gateway: buildFrontendTLSGateway([]*graph.Listener{ + { + Name: "https-listener", + Valid: true, + ValidationMode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{caRefSecret1}, + Source: v1.Listener{ + Protocol: v1.HTTPSProtocolType, + Port: 443, + }, + }, + { + Name: "https-listener-2", + Valid: true, + ValidationMode: v1.AllowInsecureFallback, + CACertificateRefs: []v1.ObjectReference{caRefSecret2}, + Source: v1.Listener{ + Protocol: v1.HTTPSProtocolType, + Port: 8443, + }, + }, + }), + sslServers: []VirtualServer{ + {Port: 443, SSL: &SSL{}}, + {Port: 8443, SSL: &SSL{}}, + }, + secretsMap: map[types.NamespacedName]*secrets.Secret{ + {Namespace: gatewayNs, Name: caRefName1}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("frontend-ca-data")}}, + }, + {Namespace: gatewayNs, Name: caRefName2}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("frontend-ca-data-2")}}, + }, + }, + expectedBundleID: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectedBundleData: []byte("frontend-ca-data"), + expectedServerBundle: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectBundle: true, + expectedSSLServerConfigs: map[int32]expectedServerConfig{ + 443: { + expectedBundleID: generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-listener"), + }), + expectedVerifyClientMode: SSLVerifyClientOn, + expectedRequireVerifiedCert: true, + }, + 8443: { + expectedBundleID: "", + expectedVerifyClientMode: SSLVerifyClientOptionalNoCA, + expectedRequireVerifiedCert: false, + }, + }, + }, + { + name: "Non-HTTPS listener is ignored", + gateway: buildFrontendTLSGateway([]*graph.Listener{{ + Name: "http-listener", + Valid: true, + CACertificateRefs: []v1.ObjectReference{caRefSecret1}, + Source: v1.Listener{ + Protocol: v1.HTTPProtocolType, + Port: 80, + }, + }}), + sslServers: []VirtualServer{{Port: 80, SSL: &SSL{}}}, + secretsMap: map[types.NamespacedName]*secrets.Secret{ + {Namespace: gatewayNs, Name: caRefName1}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("frontend-ca-data")}}, + }, + }, + expectedServerBundle: "", + expectBundle: false, + expectedSSLServerConfigs: map[int32]expectedServerConfig{ + 80: { + expectedBundleID: "", + expectedVerifyClientMode: "", + expectedRequireVerifiedCert: false, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + refCertBundles := buildFrontendTLSRefCertBundles(test.secretsMap, test.caCertConfigMaps) + + bundles := buildFrontendTLSCertBundles( + test.gateway, + test.sslServers, + refCertBundles, + ) + + if test.expectBundle { + g.Expect(bundles).To(HaveKey(test.expectedBundleID)) + g.Expect(bundles[test.expectedBundleID]).To(Equal(test.expectedBundleData)) + } else { + g.Expect(bundles).To(BeEmpty()) + } + + for _, server := range test.sslServers { + serverConfig := test.expectedSSLServerConfigs[server.Port] + g.Expect(server.SSL.ClientCertBundleID).To(Equal(serverConfig.expectedBundleID)) + g.Expect(server.SSL.VerifyClient).To(Equal(serverConfig.expectedVerifyClientMode)) + g.Expect(server.SSL.RequireVerifiedCert).To(Equal(serverConfig.expectedRequireVerifiedCert)) + } + }) + } +} + +func TestBuildFrontendTLSCertBundlesValidationModes(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + gatewayNs := "gateway-ns" + gatewayName := "test-gateway" + caRefFormat := "%s_%d_%s" + + allowInsecureRef := v1.ObjectReference{ + Name: v1.ObjectName("frontend-ca-insecure"), + Kind: v1.Kind(kinds.Secret), + Namespace: helpers.GetPointer(v1.Namespace(gatewayNs)), + } + allowValidOnlyRef := v1.ObjectReference{ + Name: v1.ObjectName("frontend-ca-valid"), + Kind: v1.Kind(kinds.Secret), + Namespace: helpers.GetPointer(v1.Namespace(gatewayNs)), + } + + gateway := &graph.Gateway{ + Valid: true, + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: gatewayNs, Name: gatewayName}, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + CACertificateRefs: []v1.ObjectReference{{Name: v1.ObjectName("default-ca")}}, + }, + }, + }, + }, + }, + }, + Listeners: []*graph.Listener{ + { + Name: "https-insecure", + Valid: true, + ValidationMode: v1.AllowInsecureFallback, + CACertificateRefs: []v1.ObjectReference{allowInsecureRef}, + Source: v1.Listener{ + Protocol: v1.HTTPSProtocolType, + Port: 443, + }, + }, + { + Name: "https-valid", + Valid: true, + ValidationMode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{allowValidOnlyRef}, + Source: v1.Listener{ + Protocol: v1.HTTPSProtocolType, + Port: 8443, + }, + }, + }, + } + + sslServers := []VirtualServer{ + {Port: 443, SSL: &SSL{}}, + {Port: 8443, SSL: &SSL{}}, + } + + secretsMap := map[types.NamespacedName]*secrets.Secret{ + {Namespace: gatewayNs, Name: "frontend-ca-insecure"}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("frontend-ca-data-insecure")}}, + }, + {Namespace: gatewayNs, Name: "frontend-ca-valid"}: { + Source: &apiv1.Secret{Data: map[string][]byte{secrets.CAKey: []byte("frontend-ca-data-valid")}}, + }, + } + + refCertBundles := buildFrontendTLSRefCertBundles(secretsMap, nil) + bundles := buildFrontendTLSCertBundles(gateway, sslServers, refCertBundles) + + insecureID := generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 443, "https-insecure"), + }) + validID := generateCertBundleID(types.NamespacedName{ + Namespace: gatewayNs, + Name: fmt.Sprintf(caRefFormat, gatewayName, 8443, "https-valid"), + }) + + g.Expect(bundles).NotTo(HaveKey(insecureID)) + g.Expect(bundles).To(HaveKey(validID)) + g.Expect(bundles[validID]).To(Equal(CertBundle([]byte("frontend-ca-data-valid")))) + + g.Expect(sslServers[0].SSL.ClientCertBundleID).To(Equal(CertBundleID(""))) + g.Expect(sslServers[0].SSL.VerifyClient).To(Equal(SSLVerifyClientOptionalNoCA)) + g.Expect(sslServers[0].SSL.RequireVerifiedCert).To(BeFalse()) + + g.Expect(sslServers[1].SSL.ClientCertBundleID).To(Equal(validID)) + g.Expect(sslServers[1].SSL.VerifyClient).To(Equal(SSLVerifyClientOn)) + g.Expect(sslServers[1].SSL.RequireVerifiedCert).To(BeTrue()) +} + +func TestBuildClientConfigForSSLServersFrontendValidationModes(t *testing.T) { + t.Parallel() + + tests := []struct { + mode v1.FrontendValidationModeType + expectedBundle CertBundleID + expectedVerifyClient SSLVerifyClientMode + name string + expectedRequireVerified bool + }{ + { + name: "empty mode defaults to AllowValidOnly", + mode: "", + expectedBundle: CertBundleID("cert_bundle_test_listener"), + expectedVerifyClient: SSLVerifyClientOn, + expectedRequireVerified: true, + }, + { + name: "AllowValidOnly requires verified cert", + mode: v1.AllowValidOnly, + expectedBundle: CertBundleID("cert_bundle_test_listener"), + expectedVerifyClient: SSLVerifyClientOn, + expectedRequireVerified: true, + }, + { + name: "AllowInsecureFallback allows any cert", + mode: v1.AllowInsecureFallback, + expectedBundle: "", + expectedVerifyClient: SSLVerifyClientOptionalNoCA, + expectedRequireVerified: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + servers := []VirtualServer{{ + Port: 443, + SSL: &SSL{}, + }} + + clientSettingsMap := map[int32]listenerClientSettings{ + 443: { + CertBundleID: CertBundleID("cert_bundle_test_listener"), + validationMode: test.mode, + }, + } + addClientSettingsToSSLServers(servers, clientSettingsMap) + + g.Expect(servers[0].SSL.ClientCertBundleID).To(Equal(test.expectedBundle)) + g.Expect(servers[0].SSL.VerifyClient).To(Equal(test.expectedVerifyClient)) + g.Expect(servers[0].SSL.RequireVerifiedCert).To(Equal(test.expectedRequireVerified)) + }) + } +} diff --git a/internal/controller/state/dataplane/types.go b/internal/controller/state/dataplane/types.go index 4449531643..9db1c5f014 100644 --- a/internal/controller/state/dataplane/types.go +++ b/internal/controller/state/dataplane/types.go @@ -182,15 +182,34 @@ const ( CookieBasedSessionPersistence SessionPersistenceType = "cookie" ) +type SSLVerifyClientMode string + +const ( + // SSLVerifyClientOn requires a client certificate that is signed by a trusted CA. + // Clients that do not present a certificate, or present one that is not CA-verified, are rejected. + SSLVerifyClientOn SSLVerifyClientMode = "on" + // SSLVerifyClientOptionalNoCA indicates that client certificates are requested but not required or validated. + // Any certificate or none is accepted without CA validation. + SSLVerifyClientOptionalNoCA SSLVerifyClientMode = "optional_no_ca" +) + // SSL is the SSL configuration for a server. type SSL struct { // Protocols specifies the SSL/TLS protocols to enable. Protocols string // Ciphers specifies the SSL/TLS ciphers to use. Ciphers string + // ClientCertBundleID is the ID of the client certificate bundle for client verification. + ClientCertBundleID CertBundleID + // VerifyClient specifies the client certificate verification mode. + // This can be "on" or "optional_no_ca". + VerifyClient SSLVerifyClientMode // KeyPairIDs are the IDs of the corresponding SSLKeyPairs for the server. // Multiple IDs allow nginx to select the appropriate certificate via SNI. KeyPairIDs []SSLKeyPairID + // RequireVerifiedCert specifies whether to require a verified client certificate for the server. + // When true, NGINX will return 444 and close the connection for clients without a trusted certificate. + RequireVerifiedCert bool // PreferServerCiphers specifies whether server ciphers should be preferred over client ciphers. PreferServerCiphers bool } diff --git a/internal/controller/state/graph/authentication_filter_test.go b/internal/controller/state/graph/authentication_filter_test.go index a18103c7d2..4443f15901 100644 --- a/internal/controller/state/graph/authentication_filter_test.go +++ b/internal/controller/state/graph/authentication_filter_test.go @@ -1353,7 +1353,7 @@ func createOpaqueCACertSecret(name string, withCAKey bool) *corev1.Secret { Data: map[string][]byte{}, } if withCAKey { - sec.Data[secrets.CAKey] = []byte("ca-cert-value") + sec.Data[secrets.CAKey] = testCert } return sec } diff --git a/internal/controller/state/graph/gateway.go b/internal/controller/state/graph/gateway.go index 49cb48779a..fd7e5b46ac 100644 --- a/internal/controller/state/graph/gateway.go +++ b/internal/controller/state/graph/gateway.go @@ -119,9 +119,8 @@ func buildGateways( SecretRef: secretRefNsName, } } else { - builtGateways[gwNsName] = &Gateway{ + gateway := &Gateway{ Source: gw, - Listeners: buildListeners(gw, resourceResolver, refGrantResolver, protectedPorts), NginxProxy: np, EffectiveNginxProxy: effectiveNginxProxy, Valid: true, @@ -129,6 +128,8 @@ func buildGateways( DeploymentName: deploymentName, SecretRef: secretRefNsName, } + gateway.Listeners = buildListeners(gateway, resourceResolver, refGrantResolver, protectedPorts) + builtGateways[gwNsName] = gateway } } @@ -150,15 +151,15 @@ func validateGatewayRefs( conds, parametersRefErrMsg := validateParametersRef(gw, npCfg) paramsRefValid := len(conds) == 0 - var secretNsName *types.NamespacedName - var tlsCond conditions.Condition + var backendSecretNsName *types.NamespacedName + var backendTLSCond conditions.Condition if gw.Spec.TLS != nil { path := field.NewPath("spec.tls") if gw.Spec.TLS.Backend != nil { backendPath := path.Child("backend") - tlsCond, secretNsName = validateGatewayTLSBackend( + backendTLSCond, backendSecretNsName = validateGatewayTLSBackend( gw, backendPath, resourceResolver, @@ -166,18 +167,18 @@ func validateGatewayRefs( ) } } - tlsValid := tlsCond == conditions.Condition{} + tlsValid := backendTLSCond == conditions.Condition{} switch { case paramsRefValid && tlsValid: conds = append(conds, conditions.NewGatewayResolvedRefs()) case !tlsValid: - conds = append(conds, tlsCond) + conds = append(conds, backendTLSCond) default: conds = append(conds, conditions.NewGatewayRefInvalid(parametersRefErrMsg)) } - return conds, secretNsName + return conds, backendSecretNsName } // validateParametersRef validates the parametersRef field of the Gateway. @@ -448,9 +449,5 @@ func validateUnsupportedGatewayFields(gw *v1.Gateway) []conditions.Condition { conds = append(conds, conditions.NewGatewayAcceptedUnsupportedField("AllowedListeners")) } - if gw.Spec.TLS != nil && gw.Spec.TLS.Frontend != nil { - conds = append(conds, conditions.NewGatewayAcceptedUnsupportedField("TLS.Frontend")) - } - return conds } diff --git a/internal/controller/state/graph/gateway_listener.go b/internal/controller/state/graph/gateway_listener.go index 042b244999..a632088b9f 100644 --- a/internal/controller/state/graph/gateway_listener.go +++ b/internal/controller/state/graph/gateway_listener.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "regexp" + "slices" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -14,6 +15,7 @@ import ( v1 "sigs.k8s.io/gateway-api/apis/v1" "github.com/nginx/nginx-gateway-fabric/v2/internal/controller/state/conditions" + "github.com/nginx/nginx-gateway-fabric/v2/internal/controller/state/graph/shared/secrets" "github.com/nginx/nginx-gateway-fabric/v2/internal/controller/state/resolver" "github.com/nginx/nginx-gateway-fabric/v2/internal/framework/helpers" "github.com/nginx/nginx-gateway-fabric/v2/internal/framework/kinds" @@ -41,18 +43,23 @@ var ( // Listener represents a Listener of the Gateway resource. // For now, we only support HTTP and HTTPS listeners. type Listener struct { - Name string - // GatewayName is the name of the Gateway resource this Listener belongs to. - GatewayName types.NamespacedName // Source holds the source of the Listener from the Gateway resource. Source v1.Listener + // AllowedRouteLabelSelector is the label selector for this Listener's allowed routes, if defined. + AllowedRouteLabelSelector labels.Selector // Routes holds the GRPC/HTTPRoutes attached to the Listener. // Only valid routes are attached. Routes map[RouteKey]*L7Route // L4Routes holds the TLSRoutes attached to the Listener. L4Routes map[L4RouteKey]*L4Route - // AllowedRouteLabelSelector is the label selector for this Listener's allowed routes, if defined. - AllowedRouteLabelSelector labels.Selector + // ValidationMode holds the TLS validation configuration for the listener. + ValidationMode v1.FrontendValidationModeType + // CACertificateRefs holds the resolved CA certificate references for the listener. + CACertificateRefs []v1.ObjectReference + // GatewayName is the name of the Gateway resource this Listener belongs to. + GatewayName types.NamespacedName + // Name is the name of the Listener. + Name string // ResolvedSecrets is the list of namespaced names of the Secrets resolved for this listener. // Only applicable for HTTPS listeners. Supports multiple certificates for SNI-based selection. ResolvedSecrets []types.NamespacedName @@ -69,18 +76,19 @@ type Listener struct { } func buildListeners( - gw *v1.Gateway, + gateway *Gateway, resourceResolver resolver.Resolver, refGrantResolver *referenceGrantResolver, protectedPorts ProtectedPorts, ) []*Listener { + gw := gateway.Source listeners := make([]*Listener, 0, len(gw.Spec.Listeners)) listenerFactory := newListenerConfiguratorFactory(gw, resourceResolver, refGrantResolver, protectedPorts) for _, gl := range gw.Spec.Listeners { configurator := listenerFactory.getConfiguratorForListener(gl) - listeners = append(listeners, configurator.configure(gl, client.ObjectKeyFromObject(gw))) + listeners = append(listeners, configurator.configure(gl, client.ObjectKeyFromObject(gw), gateway)) } return listeners @@ -157,6 +165,9 @@ func newListenerConfiguratorFactory( externalReferenceResolvers: []listenerExternalReferenceResolver{ createExternalReferencesForTLSSecretsResolver(gw.Namespace, resourceResolver, refGrantResolver), }, + frontendTLSCaCertReferenceResolvers: []listenerFrontendTLSCaCertReferenceResolver{ + createFrontendTLSCaCertReferenceResolver(resourceResolver, refGrantResolver), + }, }, tls: &listenerConfigurator{ validators: []listenerValidator{ @@ -169,7 +180,8 @@ func newListenerConfiguratorFactory( sharedPortConflictResolver, sharedOverlappingTLSConfigResolver, }, - externalReferenceResolvers: []listenerExternalReferenceResolver{}, + externalReferenceResolvers: []listenerExternalReferenceResolver{}, + frontendTLSCaCertReferenceResolvers: []listenerFrontendTLSCaCertReferenceResolver{}, }, tcp: &listenerConfigurator{ validators: []listenerValidator{ @@ -207,6 +219,11 @@ type listenerConflictResolver func(listener *Listener) // the resolver will make the listener invalid and add appropriate conditions. type listenerExternalReferenceResolver func(listener *Listener) +// listenerFrontendTLSCaCertReferenceResolver resolves the CA certificate references +// for HTTPS listeners configured for frontend TLS. +// If the reference is not resolvable, the resolver will make the listener invalid and add appropriate conditions. +type listenerFrontendTLSCaCertReferenceResolver func(listener *Listener, gw *Gateway) + // listenerConfigurator is responsible for configuring a listener. // validators, conflictResolvers, externalReferenceResolvers generate conditions for invalid fields of the listener. // Because the Gateway status includes a status field for each listener, the messages in those conditions @@ -218,9 +235,11 @@ type listenerConfigurator struct { conflictResolvers []listenerConflictResolver // externalReferenceResolvers can depend on validators - they will only be executed if all validators pass. externalReferenceResolvers []listenerExternalReferenceResolver + // frontendTLSCaCertReferenceResolvers can depend on validators - they will only be executed if all validators pass. + frontendTLSCaCertReferenceResolvers []listenerFrontendTLSCaCertReferenceResolver } -func (c *listenerConfigurator) configure(listener v1.Listener, gwNSName types.NamespacedName) *Listener { +func (c *listenerConfigurator) configure(listener v1.Listener, gwNSName types.NamespacedName, gw *Gateway) *Listener { var conds []conditions.Condition attachable := true @@ -275,6 +294,10 @@ func (c *listenerConfigurator) configure(listener v1.Listener, gwNSName types.Na externalReferenceResolver(l) } + for _, frontendTLSResolver := range c.frontendTLSCaCertReferenceResolvers { + frontendTLSResolver(l, gw) + } + return l } @@ -758,6 +781,65 @@ func createExternalReferencesForTLSSecretsResolver( } } +func createFrontendTLSCaCertReferenceResolver( + resourceResolver resolver.Resolver, + refGrantResolver *referenceGrantResolver, +) listenerFrontendTLSCaCertReferenceResolver { + return func(l *Listener, gw *Gateway) { + if gw.Source.Spec.TLS == nil || gw.Source.Spec.TLS.Frontend == nil { + return + } + + if l.Source.TLS == nil || (l.Source.TLS.Mode != nil && *l.Source.TLS.Mode != v1.TLSModeTerminate) { + return + } + + frontend := gw.Source.Spec.TLS.Frontend + + var caCertRefs []v1.ObjectReference + var validationMode v1.FrontendValidationModeType + var fieldPath *field.Path + perPortMatch := false + + for i, port := range frontend.PerPort { + if port.TLS.Validation == nil || len(port.TLS.Validation.CACertificateRefs) == 0 { + continue + } + if port.Port == l.Source.Port { + caCertRefs = port.TLS.Validation.CACertificateRefs + validationMode = port.TLS.Validation.Mode + fieldPath = field.NewPath("spec", "tls", "frontend", "perPort").Index(i).Child("tls", "validation") + perPortMatch = true + break + } + } + + if !perPortMatch && frontend.Default.Validation != nil && + len(frontend.Default.Validation.CACertificateRefs) > 0 { + caCertRefs = frontend.Default.Validation.CACertificateRefs + validationMode = frontend.Default.Validation.Mode + fieldPath = field.NewPath("spec", "tls", "frontend", "default", "validation") + } + + conds := validateFrontendTLS( + gw, + l, + fieldPath, + resourceResolver, + refGrantResolver, + caCertRefs, + ) + l.Conditions = append(l.Conditions, conds...) + l.ValidationMode = validationMode + l.CACertificateRefs = caCertRefs + + if l.ValidationMode == v1.AllowInsecureFallback { + msg := "Validation Mode: AllowInsecureFallback is set for at least one listener" + gw.Conditions = append(gw.Conditions, conditions.NewGatewayInsecureFrontendValidationMode(msg)) + } + } +} + // GetAllowedRouteLabelSelector returns a listener's AllowedRoutes label selector if it exists. func GetAllowedRouteLabelSelector(l v1.Listener) *metav1.LabelSelector { if l.AllowedRoutes != nil && l.AllowedRoutes.Namespaces != nil { @@ -856,3 +938,154 @@ func createOverlappingTLSConfigResolver() listenerConflictResolver { listenersByPort[port] = append(listenersByPort[port], l) } } + +// validateFrontendTLS validates and resolves the CA certificate references +// for a listener configured with frontend TLS. +// Returns conditions related to invalid CA certificate references. +func validateFrontendTLS( + gw *Gateway, + listener *Listener, + path *field.Path, + resourceResolver resolver.Resolver, + refGrantResolver *referenceGrantResolver, + caCertRefs []v1.ObjectReference, +) []conditions.Condition { + if gw.Source.Spec.TLS == nil || gw.Source.Spec.TLS.Frontend == nil { + return []conditions.Condition{} + } + + var conds []conditions.Condition + refNotPermittedCount := 0 + allowedKinds := []string{kinds.Secret, kinds.ConfigMap} + + for _, cert := range caCertRefs { + if kindOrGroupCond := validateObjectRefKindAndGroup( + cert, + path, + allowedKinds, + ); kindOrGroupCond != (conditions.Condition{}) { + conds = append(conds, kindOrGroupCond) + continue + } + + certNsName := getFrontendTLSCertRefNsName(cert, gw.Source) + resourceType := getFrontendTLSCertResourceType(cert.Kind) + + if refNotPermittedCond := resolveCrossNamespaceRefGrant( + cert, + certNsName, + gw.Source.Namespace, + refGrantResolver, + ); refNotPermittedCond != (conditions.Condition{}) { + gw.Conditions = append(gw.Conditions, refNotPermittedCond) + refNotPermittedCount++ + continue + } + + if err := resourceResolver.Resolve( + resourceType, + *certNsName, + resolver.WithExpectedSecretKey(secrets.CAKey), + ); err != nil { + valErr := field.Invalid(path.Child("caCertificateRefs"), certNsName, err.Error()) + msg := helpers.CapitalizeString(valErr.Error()) + conds = append(conds, conditions.NewListenerInvalidCaCertificateRef(msg)) + continue + } + } + + totalConds := len(conds) + refNotPermittedCount + if refNotPermittedCount > 0 { + msg := "Frontend TLS CA certificate refs are not permitted by any ReferenceGrant" + conds = append(conds, conditions.NewListenerUnresolvedCertificateRef( + msg, + string(v1.ListenerReasonRefNotPermitted), + )) + } + + if totalConds > 0 && totalConds == len(caCertRefs) { + msg := "All frontend TLS CA certificate refs are invalid for this listener" + conds = append(conds, conditions.NewListenerInvalidNoValidCACertificate(msg)...) + listener.Valid = false + return conds + } + + return conds +} + +// validateObjectRefKindAndGroup checks if the ObjectReference has an allowed Kind and Group. +func validateObjectRefKindAndGroup( + ref v1.ObjectReference, + path *field.Path, + allowedKinds []string, +) conditions.Condition { + if !slices.Contains(allowedKinds, string(ref.Kind)) { + valErr := field.NotSupported(path, ref.Kind, allowedKinds) + msg := helpers.CapitalizeString(valErr.Error()) + return conditions.NewListenerInvalidCaCertificateKind(msg) + } + + if ref.Group != "" && ref.Group != "core" { + valErr := field.NotSupported(path, ref.Group, []string{"core", ""}) + msg := helpers.CapitalizeString(valErr.Error()) + return conditions.NewListenerInvalidCaCertificateKind(msg) + } + + return conditions.Condition{} +} + +// getFrontendTLSCertResourceType returns the resource type for a given kind. +func getFrontendTLSCertResourceType(kind v1.Kind) resolver.ResourceType { + switch kind { + case kinds.Secret: + return resolver.ResourceTypeSecret + case kinds.ConfigMap: + return resolver.ResourceTypeConfigMap + default: + return "" + } +} + +// resolveCrossNamespaceRefGrant checks if a cross-namespace reference is allowed by any ReferenceGrant. +// Checks for both Secret and ConfigMap references. +func resolveCrossNamespaceRefGrant( + ref v1.ObjectReference, + nsName *types.NamespacedName, + gwNs string, + refGrantResolver *referenceGrantResolver, +) conditions.Condition { + if nsName.Namespace == gwNs { + return conditions.Condition{} + } + + switch ref.Kind { + case kinds.Secret: + if !refGrantResolver.refAllowed(toSecret(*nsName), fromGateway(gwNs)) { + msg := fmt.Sprintf("secret ref %s not permitted by any ReferenceGrant", nsName) + return conditions.NewGatewayRefNotPermitted(msg) + } + case kinds.ConfigMap: + if !refGrantResolver.refAllowed(toConfigMap(*nsName), fromGateway(gwNs)) { + msg := fmt.Sprintf("configmap ref %s not permitted by any ReferenceGrant", nsName) + return conditions.NewGatewayRefNotPermitted(msg) + } + } + return conditions.Condition{} +} + +// getFrontendTLSCertRefNsName returns a NamespacedName +// of the Secret or ConfigMap referenced by the Gateway for frontend TLS. +func getFrontendTLSCertRefNsName( + cert v1.ObjectReference, + gw *v1.Gateway, +) *types.NamespacedName { + caRefNs := gw.Namespace + if cert.Namespace != nil { + caRefNs = string(*cert.Namespace) + } + caCertNsName := &types.NamespacedName{ + Namespace: caRefNs, + Name: string(cert.Name), + } + return caCertNsName +} diff --git a/internal/controller/state/graph/gateway_listener_test.go b/internal/controller/state/graph/gateway_listener_test.go index 79371d3d6f..c7fc0cca17 100644 --- a/internal/controller/state/graph/gateway_listener_test.go +++ b/internal/controller/state/graph/gateway_listener_test.go @@ -1,14 +1,17 @@ package graph import ( + "errors" "fmt" "testing" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" v1 "sigs.k8s.io/gateway-api/apis/v1" "github.com/nginx/nginx-gateway-fabric/v2/internal/controller/state/conditions" + "github.com/nginx/nginx-gateway-fabric/v2/internal/controller/state/resolver" "github.com/nginx/nginx-gateway-fabric/v2/internal/controller/state/resolver/resolverfakes" "github.com/nginx/nginx-gateway-fabric/v2/internal/framework/helpers" "github.com/nginx/nginx-gateway-fabric/v2/internal/framework/kinds" @@ -1119,7 +1122,12 @@ func TestOverlappingTLSConfigCondition(t *testing.T) { refGrantResolver := newReferenceGrantResolver(nil) // Build listeners - listeners := buildListeners(test.gateway, &resolverfakes.FakeResolver{}, refGrantResolver, protectedPorts) + listeners := buildListeners( + &Gateway{Source: test.gateway}, + &resolverfakes.FakeResolver{}, + refGrantResolver, + protectedPorts, + ) if test.expectedCondition { // Check that the expected listeners have the OverlappingTLSConfig condition @@ -1220,3 +1228,1454 @@ func TestMatchesWildcard(t *testing.T) { }) } } + +func TestCreateFrontendTLSCaCertReferenceResolver(t *testing.T) { + t.Parallel() + gwNamespace := "default" + listenerName := v1.SectionName("https") + listenerPort := v1.PortNumber(443) + caCertName := "ca-cert" + + tests := []struct { + listener *Listener + gateway *Gateway + name string + expectedValidationMode v1.FrontendValidationModeType + expectedCACertRefNames []string + expectedGatewayCondLen int + expectedCACertRefs bool + expectedListenerValid bool + }{ + { + name: "Gateway with no TLS spec", + listener: &Listener{ + Source: v1.Listener{ + Name: listenerName, + Port: listenerPort, + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{}, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: false, + expectedValidationMode: "", + expectedGatewayCondLen: 0, + expectedListenerValid: true, + }, + { + name: "Gateway with TLS and no Frontend", + listener: &Listener{ + Source: v1.Listener{ + Name: listenerName, + Port: listenerPort, + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{}, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: false, + expectedValidationMode: "", + expectedGatewayCondLen: 0, + expectedListenerValid: true, + }, + { + name: "Listener with no TLS", + listener: &Listener{ + Source: v1.Listener{ + Name: listenerName, + Port: listenerPort, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName(caCertName), + Kind: v1.Kind("Secret"), + }, + }, + }, + }, + }, + }, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: false, + expectedValidationMode: "", + expectedGatewayCondLen: 0, + expectedListenerValid: true, + }, + { + name: "Listener TLS mode Passthrough", + listener: &Listener{ + Source: v1.Listener{ + Name: listenerName, + Port: listenerPort, + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModePassthrough), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName(caCertName), + Kind: v1.Kind("Secret"), + }, + }, + }, + }, + }, + }, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: false, + expectedValidationMode: "", + expectedGatewayCondLen: 0, + expectedListenerValid: true, + }, + { + name: "Per-port configuration where port matches", + listener: &Listener{ + Source: v1.Listener{ + Name: listenerName, + Port: listenerPort, + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + PerPort: []v1.TLSPortConfig{ + { + Port: listenerPort, + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName(caCertName), + Kind: v1.Kind("Secret"), + }, + }, + }, + }, + }, + }, + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowInsecureFallback, + }, + }, + }, + }, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: true, + expectedValidationMode: v1.AllowValidOnly, + expectedGatewayCondLen: 0, + expectedListenerValid: true, + }, + { + name: "Uses Default when Per-port port doesn't match", + listener: &Listener{ + Source: v1.Listener{ + Name: listenerName, + Port: v1.PortNumber(444), // Different port + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + PerPort: []v1.TLSPortConfig{ + { + Port: listenerPort, + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName(caCertName), + Kind: v1.Kind("Secret"), + }, + }, + }, + }, + }, + }, + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowInsecureFallback, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName("default-ca-cert"), + Kind: v1.Kind("Secret"), + }, + }, + }, + }, + }, + }, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: true, + expectedValidationMode: v1.AllowInsecureFallback, + expectedGatewayCondLen: 1, + expectedListenerValid: true, + }, + { + name: "Uses Default when no Per-port config", + listener: &Listener{ + Source: v1.Listener{ + Name: listenerName, + Port: listenerPort, + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName(caCertName), + Kind: v1.Kind("Secret"), + }, + }, + }, + }, + }, + }, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: true, + expectedValidationMode: v1.AllowValidOnly, + expectedGatewayCondLen: 0, + expectedListenerValid: true, + }, + { + name: "Adds gateway condition when validation mode is AllowInsecureFallback", + listener: &Listener{ + Source: v1.Listener{ + Name: listenerName, + Port: listenerPort, + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowInsecureFallback, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName(caCertName), + Kind: v1.Kind("Secret"), + }, + }, + }, + }, + }, + }, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: true, + expectedValidationMode: v1.AllowInsecureFallback, + expectedGatewayCondLen: 1, + expectedListenerValid: true, + }, + { + name: "Uses Default when no CA cert refs in Per-port", + listener: &Listener{ + Source: v1.Listener{ + Name: listenerName, + Port: listenerPort, + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + PerPort: []v1.TLSPortConfig{ + { + Port: listenerPort, + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + }, + }, + }, + }, + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowInsecureFallback, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName("default-ca-cert"), + Kind: v1.Kind("Secret"), + }, + }, + }, + }, + }, + }, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: true, + expectedValidationMode: v1.AllowInsecureFallback, + expectedGatewayCondLen: 1, + expectedListenerValid: true, + }, + { + name: "Uses Default when Per-port validation is nil", + listener: &Listener{ + Source: v1.Listener{ + Name: listenerName, + Port: listenerPort, + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + PerPort: []v1.TLSPortConfig{ + { + Port: listenerPort, + TLS: v1.TLSConfig{}, + }, + }, + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowInsecureFallback, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName("default-ca-cert"), + Kind: v1.Kind("Secret"), + }, + }, + }, + }, + }, + }, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: true, + expectedValidationMode: v1.AllowInsecureFallback, + expectedGatewayCondLen: 1, + expectedListenerValid: true, + }, + { + name: "Default and Per-port config: 443 uses Default", + listener: &Listener{ + Source: v1.Listener{ + Name: v1.SectionName("https-443"), + Port: v1.PortNumber(443), + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName("default-ca-cert"), + Kind: v1.Kind("Secret"), + }, + }, + }, + }, + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowInsecureFallback, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName("8443-secret-ca"), + Kind: v1.Kind("Secret"), + }, + { + Name: v1.ObjectName("8443-configmap-ca"), + Kind: v1.Kind("ConfigMap"), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: true, + expectedCACertRefNames: []string{"default-ca-cert"}, + expectedValidationMode: v1.AllowValidOnly, + expectedGatewayCondLen: 0, + expectedListenerValid: true, + }, + { + name: "Uses Default and Per-port config", + listener: &Listener{ + Source: v1.Listener{ + Name: v1.SectionName("https-8443"), + Port: v1.PortNumber(8443), + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName("default-ca-cert"), + Kind: v1.Kind("Secret"), + }, + }, + }, + }, + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowInsecureFallback, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName("8443-secret-ca"), + Kind: v1.Kind("Secret"), + }, + { + Name: v1.ObjectName("8443-configmap-ca"), + Kind: v1.Kind("ConfigMap"), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: true, + expectedCACertRefNames: []string{"8443-secret-ca", "8443-configmap-ca"}, + expectedValidationMode: v1.AllowInsecureFallback, + expectedGatewayCondLen: 1, + expectedListenerValid: true, + }, + { + name: "Listener TLS mode is nil (no mode specified - should default to Terminate behavior)", + listener: &Listener{ + Source: v1.Listener{ + Name: listenerName, + Port: listenerPort, + TLS: &v1.ListenerTLSConfig{}, // No Mode specified. + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName(caCertName), + Kind: v1.Kind("Secret"), + }, + }, + }, + }, + }, + }, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: true, + expectedValidationMode: v1.AllowValidOnly, + expectedGatewayCondLen: 0, + expectedListenerValid: true, + }, + { + name: "Default validation is nil and Per-port validation is nil", + listener: &Listener{ + Source: v1.Listener{ + Name: listenerName, + Port: listenerPort, + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + Default: v1.TLSConfig{}, // No Validation specified + PerPort: []v1.TLSPortConfig{}, // No Per-port configs + }, + }, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: false, + expectedValidationMode: "", + expectedGatewayCondLen: 0, + expectedListenerValid: true, + }, + { + name: "Multiple Per-port configs", + listener: &Listener{ + Source: v1.Listener{ + Name: listenerName, + Port: v1.PortNumber(8443), + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + gateway: &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gw", + Namespace: gwNamespace, + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &v1.FrontendTLSConfig{ + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + }, + }, + }, + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowInsecureFallback, + CACertificateRefs: []v1.ObjectReference{ + { + Name: v1.ObjectName("8443-ca-cert"), + Kind: v1.Kind("Secret"), + }, + }, + }, + }, + }, + }, + Default: v1.TLSConfig{ + Validation: nil, + }, + }, + }, + }, + }, + Conditions: []conditions.Condition{}, + }, + expectedCACertRefs: true, + expectedValidationMode: v1.AllowInsecureFallback, + expectedGatewayCondLen: 1, + expectedListenerValid: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + fakeResolver := &resolverfakes.FakeResolver{} + refGrantResolver := &referenceGrantResolver{} + + resolverFunc := createFrontendTLSCaCertReferenceResolver(fakeResolver, refGrantResolver) + resolverFunc(test.listener, test.gateway) + + if test.expectedCACertRefs { + g.Expect(test.listener.CACertificateRefs).ToNot(BeEmpty()) + } else { + g.Expect(test.listener.CACertificateRefs).To(BeNil()) + } + + if len(test.expectedCACertRefNames) > 0 { + g.Expect(test.listener.CACertificateRefs).To(HaveLen(len(test.expectedCACertRefNames))) + actualRefNames := make([]string, 0, len(test.listener.CACertificateRefs)) + for _, ref := range test.listener.CACertificateRefs { + actualRefNames = append(actualRefNames, string(ref.Name)) + } + g.Expect(actualRefNames).To(Equal(test.expectedCACertRefNames)) + } + + g.Expect(test.listener.ValidationMode).To(Equal(test.expectedValidationMode)) + g.Expect(test.gateway.Conditions).To(HaveLen(test.expectedGatewayCondLen)) + g.Expect(test.listener.Valid).To(Equal(test.expectedListenerValid)) + }) + } +} + +func TestCreateFrontendTLSCaCertReferenceResolverConditions(t *testing.T) { + t.Parallel() + + namespace := func(ns string) *v1.Namespace { + n := v1.Namespace(ns) + return &n + } + + secretRef := func(name string, ns *v1.Namespace) v1.ObjectReference { + return v1.ObjectReference{ + Name: v1.ObjectName(name), + Kind: v1.Kind(kinds.Secret), + Namespace: ns, + } + } + + configMapRef := func(name string, ns *v1.Namespace) v1.ObjectReference { + return v1.ObjectReference{ + Name: v1.ObjectName(name), + Kind: v1.Kind(kinds.ConfigMap), + Namespace: ns, + } + } + + invalidKindRef := func(name string) v1.ObjectReference { + return v1.ObjectReference{ + Name: v1.ObjectName(name), + Kind: v1.Kind("Service"), + } + } + + tests := []struct { + resolveErrByNN map[string]error + name string + frontendTLS v1.FrontendTLSConfig + expectedCACertRefs []v1.ObjectReference + expectedListenerReasons []string + expectedGatewayReasons []string + listenerPort v1.PortNumber + expectedListenerValid bool + }{ + { + name: "Default Secret valid", + listenerPort: v1.PortNumber(443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("default-secret", namespace("default"))}, + }, + }, + }, + expectedCACertRefs: []v1.ObjectReference{secretRef("default-secret", namespace("default"))}, + expectedListenerValid: true, + }, + { + name: "Per-port Secret valid", + listenerPort: v1.PortNumber(443), + frontendTLS: v1.FrontendTLSConfig{ + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("per-port-secret", namespace("default"))}, + }, + }, + }, + }, + }, + expectedListenerValid: true, + }, + { + name: "Default Secret with AllowInsecureFallback", + listenerPort: v1.PortNumber(443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowInsecureFallback, + CACertificateRefs: []v1.ObjectReference{secretRef("default-secret", namespace("default"))}, + }, + }, + }, + expectedGatewayReasons: []string{string(v1.GatewayReasonConfigurationChanged)}, + expectedListenerValid: true, + }, + { + name: "Per-port ConfigMap with AllowInsecureFallback", + listenerPort: v1.PortNumber(8443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("default-secret", namespace("default"))}, + }, + }, + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowInsecureFallback, + CACertificateRefs: []v1.ObjectReference{configMapRef("per-port-cm", namespace("default"))}, + }, + }, + }, + }, + }, + expectedGatewayReasons: []string{string(v1.GatewayReasonConfigurationChanged)}, + expectedListenerValid: true, + }, + { + name: "Default invalid kind", + listenerPort: v1.PortNumber(443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{invalidKindRef("bad-kind")}, + }, + }, + }, + expectedListenerReasons: []string{ + string(v1.ListenerReasonInvalidCACertificateKind), + string(v1.ListenerReasonNoValidCACertificate), + }, + expectedListenerValid: false, + }, + { + name: "Per-port invalid kind", + listenerPort: v1.PortNumber(443), + frontendTLS: v1.FrontendTLSConfig{ + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{invalidKindRef("bad-kind")}, + }, + }, + }, + }, + }, + expectedListenerReasons: []string{ + string(v1.ListenerReasonInvalidCACertificateKind), + string(v1.ListenerReasonNoValidCACertificate), + }, + expectedListenerValid: false, + }, + { + name: "Default Secret without ca.crt key", + listenerPort: v1.PortNumber(443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("default-secret-no-ca", nil)}, + }, + }, + }, + resolveErrByNN: map[string]error{"default/default-secret-no-ca": errors.New("missing key ca.crt")}, + expectedListenerReasons: []string{ + string(v1.ListenerReasonInvalidCACertificateRef), + string(v1.ListenerReasonNoValidCACertificate), + }, + expectedListenerValid: false, + }, + { + name: "Per-port Secret without ca.crt key", + listenerPort: v1.PortNumber(443), + frontendTLS: v1.FrontendTLSConfig{ + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("per-port-secret-no-ca", nil)}, + }, + }, + }, + }, + }, + resolveErrByNN: map[string]error{"default/per-port-secret-no-ca": errors.New("missing key ca.crt")}, + expectedListenerReasons: []string{ + string(v1.ListenerReasonInvalidCACertificateRef), + string(v1.ListenerReasonNoValidCACertificate), + }, + expectedListenerValid: false, + }, + { + name: "Default Secret without ca.crt key with Per-port valid resource", + listenerPort: v1.PortNumber(8443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("default-secret-no-ca", nil)}, + }, + }, + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("per-port-valid-secret", nil)}, + }, + }, + }, + }, + }, + resolveErrByNN: map[string]error{"default/default-secret-no-ca": errors.New("missing key ca.crt")}, + expectedListenerReasons: nil, + expectedListenerValid: true, + }, + { + name: "Per-port Secret without ca.crt key with Default valid resource", + listenerPort: v1.PortNumber(8443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("default-valid-secret", nil)}, + }, + }, + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("per-port-secret-no-ca", nil)}, + }, + }, + }, + }, + }, + resolveErrByNN: map[string]error{"default/per-port-secret-no-ca": errors.New("missing key ca.crt")}, + expectedListenerReasons: []string{ + string(v1.ListenerReasonInvalidCACertificateRef), + string(v1.ListenerReasonNoValidCACertificate), + }, + expectedListenerValid: false, + }, + { + name: "Default ConfigMap without ca.crt key", + listenerPort: v1.PortNumber(443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{configMapRef("default-cm-no-ca", nil)}, + }, + }, + }, + resolveErrByNN: map[string]error{"default/default-cm-no-ca": errors.New("missing key ca.crt")}, + expectedListenerReasons: []string{ + string(v1.ListenerReasonInvalidCACertificateRef), + string(v1.ListenerReasonNoValidCACertificate), + }, + expectedListenerValid: false, + }, + { + name: "Per-port ConfigMap without ca.crt key", + listenerPort: v1.PortNumber(443), + frontendTLS: v1.FrontendTLSConfig{ + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{configMapRef("per-port-cm-no-ca", nil)}, + }, + }, + }, + }, + }, + resolveErrByNN: map[string]error{"default/per-port-cm-no-ca": errors.New("missing key ca.crt")}, + expectedListenerReasons: []string{ + string(v1.ListenerReasonInvalidCACertificateRef), + string(v1.ListenerReasonNoValidCACertificate), + }, + expectedListenerValid: false, + }, + { + name: "Default ConfigMap without ca.crt key with Per-port valid resource", + listenerPort: v1.PortNumber(8443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{configMapRef("default-cm-no-ca", nil)}, + }, + }, + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("per-port-valid-secret", nil)}, + }, + }, + }, + }, + }, + resolveErrByNN: map[string]error{"default/default-cm-no-ca": errors.New("missing key ca.crt")}, + expectedListenerReasons: nil, + expectedListenerValid: true, + }, + { + name: "Per-port ConfigMap without ca.crt key with Default valid resource", + listenerPort: v1.PortNumber(8443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("default-valid-secret", nil)}, + }, + }, + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{configMapRef("per-port-cm-no-ca", nil)}, + }, + }, + }, + }, + }, + resolveErrByNN: map[string]error{"default/per-port-cm-no-ca": errors.New("missing key ca.crt")}, + expectedListenerReasons: []string{ + string(v1.ListenerReasonInvalidCACertificateRef), + string(v1.ListenerReasonNoValidCACertificate), + }, + expectedListenerValid: false, + }, + { + name: "Default with valid secret. Per-port with missing Secret and valid ConfigMap", + listenerPort: v1.PortNumber(8443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("default-secret", nil)}, + }, + }, + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + secretRef("missing-secret", nil), + configMapRef("good-cm", nil), + }, + }, + }, + }, + }, + }, + resolveErrByNN: map[string]error{"default/missing-secret": errors.New("not found")}, + expectedListenerReasons: []string{ + string(v1.ListenerReasonInvalidCACertificateRef), + }, + expectedListenerValid: true, + }, + { + name: "Per-port precedence: valid Per-port refs ignore Default missing Secret", + listenerPort: v1.PortNumber(8443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + secretRef("missing-secret", nil), + configMapRef("good-cm", nil), + }, + }, + }, + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + secretRef("default-secret", nil), + }, + }, + }, + }, + }, + }, + resolveErrByNN: map[string]error{"default/missing-secret": errors.New("not found")}, + expectedListenerReasons: nil, + expectedListenerValid: true, + }, + { + name: "Default cross-namespace Secret without ReferenceGrant", + listenerPort: v1.PortNumber(443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("xns-secret", namespace("other"))}, + }, + }, + }, + expectedListenerReasons: []string{ + string(v1.ListenerReasonRefNotPermitted), + string(v1.ListenerReasonNoValidCACertificate), + }, + expectedGatewayReasons: []string{string(v1.GatewayReasonRefNotPermitted)}, + expectedListenerValid: false, + }, + { + name: "Per-port cross-namespace Secret without ReferenceGrant", + listenerPort: v1.PortNumber(443), + frontendTLS: v1.FrontendTLSConfig{ + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("xns-secret", namespace("other"))}, + }, + }, + }, + }, + }, + expectedListenerReasons: []string{ + string(v1.ListenerReasonRefNotPermitted), + string(v1.ListenerReasonNoValidCACertificate), + }, + expectedGatewayReasons: []string{string(v1.GatewayReasonRefNotPermitted)}, + expectedListenerValid: false, + }, + { + name: "Per-port precedence: valid Per-port secret ignores Default cross-namespace ConfigMap", + listenerPort: v1.PortNumber(8443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + secretRef("local-secret", nil), + configMapRef("xns-cm", namespace("other")), + }, + }, + }, + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + secretRef("default-secret", nil), + }, + }, + }, + }, + }, + }, + expectedListenerReasons: nil, + expectedListenerValid: true, + }, + { + name: "Per-port Secret in same ns + cross-namespace ConfigMap without ReferenceGrant", + listenerPort: v1.PortNumber(8443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("default-secret", nil)}, + }, + }, + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{ + secretRef("local-secret", nil), + configMapRef("xns-cm", namespace("other")), + }, + }, + }, + }, + }, + }, + expectedListenerReasons: []string{string(v1.ListenerReasonRefNotPermitted)}, + expectedGatewayReasons: []string{string(v1.GatewayReasonRefNotPermitted)}, + expectedListenerValid: true, + }, + { + name: "Default invalid kind with per-port valid", + listenerPort: v1.PortNumber(8443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("default-secret", nil)}, + }, + }, + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{invalidKindRef("bad-kind")}, + }, + }, + }, + }, + }, + expectedListenerReasons: []string{ + string(v1.ListenerReasonInvalidCACertificateKind), + string(v1.ListenerReasonNoValidCACertificate), + }, + expectedListenerValid: false, + }, + { + name: "Per-port invalid kind with Default valid", + listenerPort: v1.PortNumber(8443), + frontendTLS: v1.FrontendTLSConfig{ + Default: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{secretRef("default-secret", nil)}, + }, + }, + PerPort: []v1.TLSPortConfig{ + { + Port: v1.PortNumber(8443), + TLS: v1.TLSConfig{ + Validation: &v1.FrontendTLSValidation{ + Mode: v1.AllowValidOnly, + CACertificateRefs: []v1.ObjectReference{invalidKindRef("bad-kind")}, + }, + }, + }, + }, + }, + expectedListenerReasons: []string{ + string(v1.ListenerReasonInvalidCACertificateKind), + string(v1.ListenerReasonNoValidCACertificate), + }, + expectedListenerValid: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + gw := &Gateway{ + Source: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw", + Namespace: "default", + }, + Spec: v1.GatewaySpec{ + TLS: &v1.GatewayTLSConfig{ + Frontend: &test.frontendTLS, + }, + }, + }, + } + + listener := &Listener{ + Name: fmt.Sprintf("https-%d", test.listenerPort), + Source: v1.Listener{ + Name: v1.SectionName(fmt.Sprintf("https-%d", test.listenerPort)), + Port: test.listenerPort, + TLS: &v1.ListenerTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + } + + fakeResolver := &resolverfakes.FakeResolver{} + if test.resolveErrByNN != nil { + fakeResolver.ResolveCalls(func( + _ resolver.ResourceType, + nsName types.NamespacedName, + _ ...resolver.ResolveOption, + ) error { + if err, exists := test.resolveErrByNN[nsName.String()]; exists { + return err + } + return nil + }) + } + + resolverFunc := createFrontendTLSCaCertReferenceResolver(fakeResolver, newReferenceGrantResolver(nil)) + resolverFunc(listener, gw) + + listenerReasons := make([]string, 0, len(listener.Conditions)) + for _, cond := range listener.Conditions { + listenerReasons = append(listenerReasons, cond.Reason) + } + + gatewayReasons := make([]string, 0, len(gw.Conditions)) + for _, cond := range gw.Conditions { + gatewayReasons = append(gatewayReasons, cond.Reason) + } + + for _, expectedReason := range test.expectedListenerReasons { + g.Expect(listenerReasons).To(ContainElement(expectedReason)) + } + + for _, expectedReason := range test.expectedGatewayReasons { + g.Expect(gatewayReasons).To(ContainElement(expectedReason)) + } + + if len(test.expectedListenerReasons) == 0 { + g.Expect(listener.Conditions).To(BeEmpty()) + } + + if len(test.expectedGatewayReasons) == 0 { + g.Expect(gw.Conditions).To(BeEmpty()) + } + + g.Expect(listener.Valid).To(Equal(test.expectedListenerValid)) + if test.expectedCACertRefs != nil { + g.Expect(listener.CACertificateRefs).To(Equal(test.expectedCACertRefs)) + } + }) + } +} + +func TestGetFrontendTLSCertReferences(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref v1.ObjectReference + expectedNsName types.NamespacedName + }{ + { + name: "defaults empty namespace to gateway namespace", + ref: v1.ObjectReference{Name: v1.ObjectName("ca-secret"), Kind: v1.Kind(kinds.Secret)}, + expectedNsName: types.NamespacedName{Namespace: "gateway-ns", Name: "ca-secret"}, + }, + { + name: "preserves explicit namespace", + ref: v1.ObjectReference{ + Name: v1.ObjectName("ca-secret"), + Kind: v1.Kind(kinds.Secret), + Namespace: helpers.GetPointer(v1.Namespace("other-ns")), + }, + expectedNsName: types.NamespacedName{Namespace: "other-ns", Name: "ca-secret"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + nsName := getFrontendTLSCertRefNsName(test.ref, &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "gateway-ns"}, + }) + g.Expect(nsName).To(Equal(&test.expectedNsName)) + }) + } +} diff --git a/internal/controller/state/graph/gateway_test.go b/internal/controller/state/graph/gateway_test.go index 4a105a5d59..4672fa155c 100644 --- a/internal/controller/state/graph/gateway_test.go +++ b/internal/controller/state/graph/gateway_test.go @@ -1792,13 +1792,7 @@ func TestBuildGateway(t *testing.T) { Kind: "wrong-kind", // Invalid reference Name: "invalid-ref", }, - TLS: &v1.GatewayTLSConfig{ - Frontend: &v1.FrontendTLSConfig{ - Default: v1.TLSConfig{ - Validation: &v1.FrontendTLSValidation{}, - }, - }, - }, + allowedListeners: &v1.AllowedListeners{}, }), gatewayClass: validGCWithNp, expected: map[types.NamespacedName]*Gateway{ @@ -1825,7 +1819,7 @@ func TestBuildGateway(t *testing.T) { IPFamily: helpers.GetPointer(ngfAPIv1alpha2.Dual), }, Conditions: []conditions.Condition{ - conditions.NewGatewayAcceptedUnsupportedField("TLS.Frontend"), + conditions.NewGatewayAcceptedUnsupportedField("AllowedListeners"), conditions.NewGatewayInvalidParameters( "Spec.infrastructure.parametersRef.kind: Unsupported value: \"wrong-kind\": supported values: \"NginxProxy\"", ), @@ -2492,21 +2486,6 @@ func TestValidateUnsupportedGatewayFields(t *testing.T) { conditions.NewGatewayAcceptedUnsupportedField("AllowedListeners"), }, }, - { - name: "Multiple unsupported fields: AllowedListeners and Frontend TLS", - gateway: &v1.Gateway{ - Spec: v1.GatewaySpec{ - AllowedListeners: &v1.AllowedListeners{}, - TLS: &v1.GatewayTLSConfig{ - Frontend: &v1.FrontendTLSConfig{}, - }, - }, - }, - expectedConds: []conditions.Condition{ - conditions.NewGatewayAcceptedUnsupportedField("AllowedListeners"), - conditions.NewGatewayAcceptedUnsupportedField("TLS.Frontend"), - }, - }, } for _, test := range tests { diff --git a/internal/controller/state/graph/reference_grant.go b/internal/controller/state/graph/reference_grant.go index 282bdf2c63..bda6b766f7 100644 --- a/internal/controller/state/graph/reference_grant.go +++ b/internal/controller/state/graph/reference_grant.go @@ -42,7 +42,15 @@ type fromResource struct { func toSecret(nsname types.NamespacedName) toResource { return toResource{ - kind: "Secret", + kind: kinds.Secret, + name: nsname.Name, + namespace: nsname.Namespace, + } +} + +func toConfigMap(nsname types.NamespacedName) toResource { + return toResource{ + kind: kinds.ConfigMap, name: nsname.Name, namespace: nsname.Namespace, } diff --git a/internal/controller/state/graph/reference_grant_test.go b/internal/controller/state/graph/reference_grant_test.go index dd2ba4b017..e66812020a 100644 --- a/internal/controller/state/graph/reference_grant_test.go +++ b/internal/controller/state/graph/reference_grant_test.go @@ -184,6 +184,20 @@ func TestToSecret(t *testing.T) { g.Expect(ref).To(Equal(exp)) } +func TestToConfigMap(t *testing.T) { + t.Parallel() + ref := toConfigMap(types.NamespacedName{Namespace: "ns", Name: "config-map"}) + + exp := toResource{ + kind: "ConfigMap", + namespace: "ns", + name: "config-map", + } + + g := NewWithT(t) + g.Expect(ref).To(Equal(exp)) +} + func TestToService(t *testing.T) { t.Parallel() ref := toService(types.NamespacedName{Namespace: "ns", Name: "service"}) diff --git a/internal/controller/state/resolver/configmaps_test.go b/internal/controller/state/resolver/configmaps_test.go index e6cfcf4418..96acc4772d 100644 --- a/internal/controller/state/resolver/configmaps_test.go +++ b/internal/controller/state/resolver/configmaps_test.go @@ -68,34 +68,32 @@ func TestResolve(t *testing.T) { resourceResolver := resolver.NewResourceResolver(resources) tests := []struct { - name string - nsname types.NamespacedName - errorExpected bool + name string + nsname types.NamespacedName + expectedErrMsg string }{ { - name: "valid configmap1", - nsname: types.NamespacedName{Namespace: "test", Name: "configmap1"}, - errorExpected: false, + name: "valid configmap1", + nsname: types.NamespacedName{Namespace: "test", Name: "configmap1"}, }, { - name: "valid configmap2", - nsname: types.NamespacedName{Namespace: "test", Name: "configmap2"}, - errorExpected: false, + name: "valid configmap2", + nsname: types.NamespacedName{Namespace: "test", Name: "configmap2"}, }, { - name: "invalid configmap", - nsname: types.NamespacedName{Namespace: "test", Name: "invalid"}, - errorExpected: true, + name: "invalid configmap", + nsname: types.NamespacedName{Namespace: "test", Name: "invalid"}, + expectedErrMsg: "the data field \"ca.crt\" must hold a valid CERTIFICATE PEM block", }, { - name: "non-existent configmap", - nsname: types.NamespacedName{Namespace: "test", Name: "non-existent"}, - errorExpected: true, + name: "non-existent configmap", + nsname: types.NamespacedName{Namespace: "test", Name: "non-existent"}, + expectedErrMsg: "ConfigMap test/non-existent does not exist, or is missing an expected key", }, { - name: "configmap missing ca entry", - nsname: types.NamespacedName{Namespace: "test", Name: "nocaentry"}, - errorExpected: true, + name: "configmap missing ca entry", + nsname: types.NamespacedName{Namespace: "test", Name: "nocaentry"}, + expectedErrMsg: "ConfigMap does not have the data or binaryData field ca.crt", }, } @@ -104,10 +102,10 @@ func TestResolve(t *testing.T) { g := NewWithT(t) err := resourceResolver.Resolve(resolver.ResourceTypeConfigMap, test.nsname) - if test.errorExpected { - g.Expect(err).To(HaveOccurred()) - } else { + if test.expectedErrMsg == "" { g.Expect(err).ToNot(HaveOccurred()) + } else { + g.Expect(err).To(MatchError(test.expectedErrMsg)) } }) } diff --git a/internal/controller/state/resolver/resolver.go b/internal/controller/state/resolver/resolver.go index 9f2d9ed293..7565cdb2c2 100644 --- a/internal/controller/state/resolver/resolver.go +++ b/internal/controller/state/resolver/resolver.go @@ -351,7 +351,11 @@ func (r *ResourceResolver) Resolve(resType ResourceType, nsname types.Namespaced defer r.lock.Unlock() if !exist { - resource.setError(fmt.Errorf("%s %s does not exist", resType, nsname.String())) + errMsg := fmt.Sprintf("%s %s does not exist", resType, nsname.String()) + if resType == ResourceTypeConfigMap { + errMsg += ", or is missing an expected key" + } + resource.setError(fmt.Errorf("%s", errMsg)) r.resolvedResources[key] = resource return resource.error() } diff --git a/internal/controller/state/resolver/secrets.go b/internal/controller/state/resolver/secrets.go index dc0c9487d6..3762d31c7a 100644 --- a/internal/controller/state/resolver/secrets.go +++ b/internal/controller/state/resolver/secrets.go @@ -49,7 +49,13 @@ func (s *secretEntry) validate(obj client.Object) { // for optional root certificate authority if _, exists := secret.Data[secrets.CAKey]; exists { cert.CACert = secret.Data[secrets.CAKey] - validationErr = secrets.ValidateCA(cert.CACert) + if validationErr == nil { + validationErr = secrets.ValidateCA(cert.CACert) + } + } else if s.expectedKey == secrets.CAKey { + // For Frontend TLS, we need to ensure the ca.crt key exists + // as TLS secrets are considered valid by default without a CA certificate. + validationErr = fmt.Errorf("missing expected key %q in secret %s/%s", secrets.CAKey, secret.Namespace, secret.Name) } certBundle = secrets.NewCertificateBundle(client.ObjectKeyFromObject(secret), "Secret", cert) @@ -74,25 +80,37 @@ func (s *secretEntry) needsRevalidation(opts *resolveOptions) bool { return opts.expectedSecretKey != "" && opts.expectedSecretKey != s.expectedKey } -// revalidate checks are only done on Opaque secrets with an expected key. +// revalidate re-validates the secret against new resolve options. +// For TLS secrets, it re-runs the full validation logic to check for the new expectedKey. +// For Opaque secrets, it validates the expected key exists. func (s *secretEntry) revalidate(opts *resolveOptions, obj client.Object) error { secret, ok := obj.(*v1.Secret) if !ok { panic(fmt.Sprintf("expected Secret object, got %T", obj)) } - if secret.Type != v1.SecretTypeOpaque { + switch secret.Type { + case v1.SecretTypeTLS: + // Re-run full validation for TLS secrets with the new expectedKey + s.expectedKey = opts.expectedSecretKey + s.validate(obj) + return s.error() + case v1.SecretTypeOpaque: + err := validateOpaqueSecretKey(secret, opts.expectedSecretKey) + s.expectedKey = opts.expectedSecretKey + s.setError(err) + return err + default: return fmt.Errorf("unsupported secret type %q", secret.Type) } - - err := validateOpaqueSecretKey(secret, opts.expectedSecretKey) - s.expectedKey = opts.expectedSecretKey - s.setError(err) - return err } func validateOpaqueSecretKey(secret *v1.Secret, key string) error { - if data, exists := secret.Data[key]; !exists || len(data) == 0 { + if data, exists := secret.Data[key]; exists && len(data) > 0 { + if key == secrets.CAKey { + return secrets.ValidateCA(data) + } + } else { return fmt.Errorf( "opaque secret %s/%s does not contain the expected key %q", secret.Namespace, @@ -100,6 +118,5 @@ func validateOpaqueSecretKey(secret *v1.Secret, key string) error { key, ) } - return nil } diff --git a/internal/controller/state/resolver/secrets_test.go b/internal/controller/state/resolver/secrets_test.go index 94465c0cee..45f671fdef 100644 --- a/internal/controller/state/resolver/secrets_test.go +++ b/internal/controller/state/resolver/secrets_test.go @@ -280,7 +280,7 @@ func TestSecretResolver(t *testing.T) { Name: "valid-ca-cert", }, Data: map[string][]byte{ - secrets.CAKey: []byte("ca-cert-secret"), + secrets.CAKey: []byte(caBlock), }, Type: v1.SecretTypeOpaque, } @@ -326,7 +326,7 @@ func TestSecretResolver(t *testing.T) { }, Data: map[string][]byte{ secrets.ClientSecretKey: []byte("client-secret"), - secrets.CAKey: []byte("ca-cert"), + secrets.CAKey: []byte(caBlock), }, Type: v1.SecretTypeOpaque, } @@ -352,10 +352,24 @@ func TestSecretResolver(t *testing.T) { Name: "opaque-ca-key-only", }, Data: map[string][]byte{ - secrets.CAKey: []byte("ca-cert"), + secrets.CAKey: []byte(caBlock), }, Type: v1.SecretTypeOpaque, } + + // tlsNoCa is a TLS secret without a CA certificate. + // used to verify that the presence of a CA certificate is required for FrontendTLS Secrets. + tlsNoCa = &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "tls-no-ca", + }, + Data: map[string][]byte{ + v1.TLSCertKey: cert, + v1.TLSPrivateKeyKey: key, + }, + Type: v1.SecretTypeTLS, + } ) resourceResolver := resolver.NewResourceResolver( @@ -428,6 +442,10 @@ func TestSecretResolver(t *testing.T) { NamespacedName: client.ObjectKeyFromObject(opaqueCAKeyOnly), ResourceType: resolver.ResourceTypeSecret, }: opaqueCAKeyOnly, + { + NamespacedName: client.ObjectKeyFromObject(tlsNoCa), + ResourceType: resolver.ResourceTypeSecret, + }: tlsNoCa, }) tests := []struct { @@ -554,6 +572,22 @@ func TestSecretResolver(t *testing.T) { nsname: client.ObjectKeyFromObject(opaqueCAKeyOnly), resolveOpts: []resolver.ResolveOption{resolver.WithExpectedSecretKey(secrets.CAKey)}, }, + { + name: "tls secret with ca certificate is valid when first resolved without expected key", + nsname: client.ObjectKeyFromObject(validSecret3), + }, + { + name: "tls secret with ca certificate is valid when resolved again with ca.crt key " + + "after being cached without expected key", + nsname: client.ObjectKeyFromObject(validSecret3), + resolveOpts: []resolver.ResolveOption{resolver.WithExpectedSecretKey(secrets.CAKey)}, + }, + { + name: "tls secret with expected ca.crt key", + nsname: client.ObjectKeyFromObject(tlsNoCa), + resolveOpts: []resolver.ResolveOption{resolver.WithExpectedSecretKey(secrets.CAKey)}, + expectedErrMsg: "missing expected key \"ca.crt\" in secret test/tls-no-ca", + }, } // Not running tests with t.Run(...) because the last one (getResolvedSecrets) depends on the execution of @@ -659,6 +693,16 @@ func TestSecretResolver(t *testing.T) { client.ObjectKeyFromObject(opaqueCAKeyOnly): { Source: opaqueCAKeyOnly, }, + client.ObjectKeyFromObject(tlsNoCa): { + Source: tlsNoCa, + CertBundle: secrets.NewCertificateBundle( + client.ObjectKeyFromObject(tlsNoCa), + "Secret", + &secrets.Certificate{ + TLSCert: cert, + TLSPrivateKey: key, + }), + }, } resolved := resourceResolver.GetSecrets() diff --git a/internal/controller/status/gatewayclass.go b/internal/controller/status/gatewayclass.go index c462fe6126..ebae9cc921 100644 --- a/internal/controller/status/gatewayclass.go +++ b/internal/controller/status/gatewayclass.go @@ -32,6 +32,8 @@ func supportedFeatures(experimental bool) []gatewayv1.SupportedFeature { features.SupportGatewayStaticAddresses, features.SupportGatewayBackendClientCertificate, features.SupportGatewayHTTPSListenerDetectMisdirectedRequests, + features.SupportGatewayFrontendClientCertificateValidation, + features.SupportGatewayFrontendClientCertificateValidationInsecureFallback, // HTTPRoute extended features.SupportHTTPRouteBackendProtocolWebSocket, diff --git a/internal/controller/status/gatewayclass_test.go b/internal/controller/status/gatewayclass_test.go index 83fd703f91..4f08b07b63 100644 --- a/internal/controller/status/gatewayclass_test.go +++ b/internal/controller/status/gatewayclass_test.go @@ -44,6 +44,8 @@ func TestSupportedFeatures(t *testing.T) { gatewayv1.FeatureName(features.SupportTLSRoute), gatewayv1.FeatureName(features.SupportHTTPRouteCORS), gatewayv1.FeatureName(features.SupportGatewayHTTPSListenerDetectMisdirectedRequests), + gatewayv1.FeatureName(features.SupportGatewayFrontendClientCertificateValidation), + gatewayv1.FeatureName(features.SupportGatewayFrontendClientCertificateValidationInsecureFallback), } experimentalFeatures := []gatewayv1.FeatureName{ diff --git a/internal/controller/status/prepare_requests_test.go b/internal/controller/status/prepare_requests_test.go index ee9988ac5e..8357d45af3 100644 --- a/internal/controller/status/prepare_requests_test.go +++ b/internal/controller/status/prepare_requests_test.go @@ -886,20 +886,20 @@ func TestBuildGatewayStatuses(t *testing.T) { validListenerConditions := []metav1.Condition{ { - Type: string(v1.ListenerConditionAccepted), + Type: string(v1.ListenerConditionProgrammed), Status: metav1.ConditionTrue, ObservedGeneration: 2, LastTransitionTime: transitionTime, - Reason: string(v1.ListenerReasonAccepted), - Message: "The Listener is accepted", + Reason: string(v1.ListenerReasonProgrammed), + Message: "The Listener is programmed", }, { - Type: string(v1.ListenerConditionProgrammed), + Type: string(v1.ListenerConditionAccepted), Status: metav1.ConditionTrue, ObservedGeneration: 2, LastTransitionTime: transitionTime, - Reason: string(v1.ListenerReasonProgrammed), - Message: "The Listener is programmed", + Reason: string(v1.ListenerReasonAccepted), + Message: "The Listener is accepted", }, { Type: string(v1.ListenerConditionResolvedRefs), @@ -1504,6 +1504,211 @@ func TestBuildGatewayStatuses(t *testing.T) { }, }, }, + { + name: "valid gateway; valid listeners; one unresolved frontend tls ca cert ref", + gateway: &graph.Gateway{ + Source: createGateway(), + Listeners: []*graph.Listener{ + { + Name: "listener-valid-1", + Valid: true, + Conditions: []conditions.Condition{ + conditions.NewListenerUnresolvedCertificateRef( + `certificate ref "test/my-ca-cert" could not be resolved`, + string(v1.ListenerReasonInvalidCACertificateRef), + ), + }, + Routes: map[graph.RouteKey]*graph.L7Route{routeKey: {}}, + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + expected: map[types.NamespacedName]v1.GatewayStatus{ + {Namespace: "test", Name: "gateway"}: { + Addresses: addr, + Conditions: []metav1.Condition{ + { + Type: string(v1.GatewayConditionAccepted), + Status: metav1.ConditionTrue, + ObservedGeneration: 2, + LastTransitionTime: transitionTime, + Reason: string(v1.GatewayReasonAccepted), + Message: "The Gateway is accepted", + }, + { + Type: string(v1.GatewayConditionProgrammed), + Status: metav1.ConditionTrue, + ObservedGeneration: 2, + LastTransitionTime: transitionTime, + Reason: string(v1.GatewayReasonProgrammed), + Message: "The Gateway is programmed", + }, + }, + Listeners: []v1.ListenerStatus{ + { + Name: "listener-valid-1", + AttachedRoutes: 1, + Conditions: []metav1.Condition{ + { + Type: string(v1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: 2, + LastTransitionTime: transitionTime, + Reason: string(v1.ListenerReasonInvalidCACertificateRef), + Message: `certificate ref "test/my-ca-cert" could not be resolved`, + }, + { + Type: string(v1.ListenerConditionProgrammed), + Status: metav1.ConditionTrue, + ObservedGeneration: 2, + LastTransitionTime: transitionTime, + Reason: string(v1.ListenerReasonProgrammed), + Message: "The Listener is programmed", + }, + { + Type: string(v1.ListenerConditionAccepted), + Status: metav1.ConditionTrue, + ObservedGeneration: 2, + LastTransitionTime: transitionTime, + Reason: string(v1.ListenerReasonAccepted), + Message: "The Listener is accepted", + }, + { + Type: string(v1.ListenerConditionConflicted), + Status: metav1.ConditionFalse, + ObservedGeneration: 2, + LastTransitionTime: transitionTime, + Reason: string(v1.ListenerReasonNoConflicts), + Message: "No conflicts", + }, + }, + }, + }, + }, + }, + }, + { + name: "valid gateway; invalid listener with all invalid frontend tls ca cert refs", + gateway: &graph.Gateway{ + Source: createGateway(), + Listeners: []*graph.Listener{ + { + Name: "listener-invalid-ca-certs", + Valid: false, + Conditions: conditions.NewListenerInvalidNoValidCACertificate( + "all CA certificate refs are invalid", + ), + }, + }, + Valid: true, + Conditions: []conditions.Condition{}, + }, + expected: map[types.NamespacedName]v1.GatewayStatus{ + {Namespace: "test", Name: "gateway"}: { + Addresses: addr, + Conditions: []metav1.Condition{ + { + Type: string(v1.GatewayConditionAccepted), + Status: metav1.ConditionFalse, + ObservedGeneration: 2, + LastTransitionTime: transitionTime, + Reason: string(v1.GatewayReasonListenersNotValid), + Message: "The Gateway has no valid listeners", + }, + { + Type: string(v1.GatewayConditionProgrammed), + Status: metav1.ConditionFalse, + ObservedGeneration: 2, + LastTransitionTime: transitionTime, + Reason: string(v1.GatewayReasonInvalid), + Message: "The Gateway has no valid listeners", + }, + }, + Listeners: []v1.ListenerStatus{ + { + Name: "listener-invalid-ca-certs", + AttachedRoutes: 0, + Conditions: []metav1.Condition{ + { + Type: string(v1.ListenerConditionAccepted), + Status: metav1.ConditionFalse, + ObservedGeneration: 2, + LastTransitionTime: transitionTime, + Reason: string(v1.ListenerReasonNoValidCACertificate), + Message: "all CA certificate refs are invalid", + }, + { + Type: string(v1.ListenerConditionProgrammed), + Status: metav1.ConditionFalse, + ObservedGeneration: 2, + LastTransitionTime: transitionTime, + Reason: string(v1.ListenerReasonInvalid), + Message: "all CA certificate refs are invalid", + }, + }, + }, + }, + }, + }, + }, + { + name: "valid gateway; valid listener; frontend tls validation mode set to AllowInsecureFallback", + gateway: &graph.Gateway{ + Source: createGateway(), + Listeners: []*graph.Listener{ + { + Name: "listener-valid-1", + Valid: true, + Routes: map[graph.RouteKey]*graph.L7Route{routeKey: {}}, + }, + }, + Valid: true, + Conditions: []conditions.Condition{ + conditions.NewGatewayInsecureFrontendValidationMode( + "Validation Mode: AllowInsecureFallback is set for at least one listener", + ), + }, + }, + expected: map[types.NamespacedName]v1.GatewayStatus{ + {Namespace: "test", Name: "gateway"}: { + Addresses: addr, + Conditions: []metav1.Condition{ + { + Type: string(v1.GatewayConditionAccepted), + Status: metav1.ConditionTrue, + ObservedGeneration: 2, + LastTransitionTime: transitionTime, + Reason: string(v1.GatewayReasonAccepted), + Message: "The Gateway is accepted", + }, + { + Type: string(v1.GatewayConditionProgrammed), + Status: metav1.ConditionTrue, + ObservedGeneration: 2, + LastTransitionTime: transitionTime, + Reason: string(v1.GatewayReasonProgrammed), + Message: "The Gateway is programmed", + }, + { + Type: string(v1.GatewayConditionInsecureFrontendValidationMode), + Status: metav1.ConditionTrue, + ObservedGeneration: 2, + LastTransitionTime: transitionTime, + Reason: string(v1.GatewayReasonConfigurationChanged), + Message: "Validation Mode: AllowInsecureFallback is set for at least one listener", + }, + }, + Listeners: []v1.ListenerStatus{ + { + Name: "listener-valid-1", + AttachedRoutes: 1, + Conditions: validListenerConditions, + }, + }, + }, + }, + }, } for _, test := range tests {