diff --git a/internal/adc/translator/apisixtls.go b/internal/adc/translator/apisixtls.go index 2f05facf..d99afb5e 100644 --- a/internal/adc/translator/apisixtls.go +++ b/internal/adc/translator/apisixtls.go @@ -27,6 +27,11 @@ import ( "github.com/apache/apisix-ingress-controller/internal/controller/label" "github.com/apache/apisix-ingress-controller/internal/id" "github.com/apache/apisix-ingress-controller/internal/provider" +<<<<<<< HEAD +======= + sslutils "github.com/apache/apisix-ingress-controller/internal/ssl" + internaltypes "github.com/apache/apisix-ingress-controller/internal/types" +>>>>>>> 351d20a5 (feat: add certificate conflict detection to admission webhooks (#2603)) ) func (t *Translator) TranslateApisixTls(tctx *provider.TranslateContext, tls *apiv2.ApisixTls) (*TranslateResult, error) { @@ -43,7 +48,7 @@ func (t *Translator) TranslateApisixTls(tctx *provider.TranslateContext, tls *ap } // Extract cert and key from secret - cert, key, err := extractKeyPair(secret, true) + cert, key, err := sslutils.ExtractKeyPair(secret, true) if err != nil { return nil, err } @@ -80,7 +85,7 @@ func (t *Translator) TranslateApisixTls(tctx *provider.TranslateContext, tls *ap return nil, fmt.Errorf("client CA secret %s not found", caSecretKey.String()) } - ca, _, err := extractKeyPair(caSecret, false) + ca, _, err := sslutils.ExtractKeyPair(caSecret, false) if err != nil { return nil, err } diff --git a/internal/adc/translator/apisixupstream.go b/internal/adc/translator/apisixupstream.go index b56791dc..21bbcba2 100644 --- a/internal/adc/translator/apisixupstream.go +++ b/internal/adc/translator/apisixupstream.go @@ -30,6 +30,7 @@ import ( "github.com/apache/apisix-ingress-controller/api/adc" apiv2 "github.com/apache/apisix-ingress-controller/api/v2" "github.com/apache/apisix-ingress-controller/internal/provider" + sslutils "github.com/apache/apisix-ingress-controller/internal/ssl" "github.com/apache/apisix-ingress-controller/internal/utils" ) @@ -160,7 +161,7 @@ func translateApisixUpstreamClientTLS(tctx *provider.TranslateContext, au *apiv2 return errors.Errorf("sercret %s not found", secretNN) } - cert, key, err := extractKeyPair(secret, true) + cert, key, err := sslutils.ExtractKeyPair(secret, true) if err != nil { return err } diff --git a/internal/adc/translator/gateway.go b/internal/adc/translator/gateway.go index 43fc765f..8b90daf1 100644 --- a/internal/adc/translator/gateway.go +++ b/internal/adc/translator/gateway.go @@ -18,16 +18,17 @@ package translator import ( - "crypto/x509" "encoding/json" - "encoding/pem" "fmt" "slices" "github.com/api7/gopkg/pkg/log" "github.com/pkg/errors" +<<<<<<< HEAD "go.uber.org/zap" corev1 "k8s.io/api/core/v1" +======= +>>>>>>> 351d20a5 (feat: add certificate conflict detection to admission webhooks (#2603)) "k8s.io/apimachinery/pkg/types" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" @@ -36,6 +37,7 @@ import ( "github.com/apache/apisix-ingress-controller/internal/controller/label" "github.com/apache/apisix-ingress-controller/internal/id" "github.com/apache/apisix-ingress-controller/internal/provider" + sslutils "github.com/apache/apisix-ingress-controller/internal/ssl" internaltypes "github.com/apache/apisix-ingress-controller/internal/types" "github.com/apache/apisix-ingress-controller/internal/utils" ) @@ -100,7 +102,7 @@ func (t *Translator) translateSecret(tctx *provider.TranslateContext, listener g log.Errorw("secret data is nil", zap.Any("secret", secret)) return nil, fmt.Errorf("no secret data found for %s/%s", ns, name) } - cert, key, err := extractKeyPair(secret, true) + cert, key, err := sslutils.ExtractKeyPair(secret, true) if err != nil { log.Errorw("failed to extract key pair", zap.Error(err), zap.Any("secret", secret)) return nil, err @@ -113,7 +115,7 @@ func (t *Translator) translateSecret(tctx *provider.TranslateContext, listener g if listener.Hostname != nil && *listener.Hostname != "" { sslObj.Snis = append(sslObj.Snis, string(*listener.Hostname)) } else { - hosts, err := extractHost(cert) + hosts, err := sslutils.ExtractHostsFromCertificate(cert) if err != nil { return nil, err } @@ -141,68 +143,6 @@ func (t *Translator) translateSecret(tctx *provider.TranslateContext, listener g return sslObjs, nil } -func extractHost(cert []byte) ([]string, error) { - block, _ := pem.Decode(cert) - if block == nil { - return nil, errors.New("parse certificate: not in PEM format") - } - der, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return nil, errors.Wrap(err, "parse certificate") - } - hosts := make([]string, 0, len(der.DNSNames)) - for _, dnsName := range der.DNSNames { - if dnsName != "*" { - hosts = append(hosts, dnsName) - } - } - return hosts, nil -} - -func extractKeyPair(s *corev1.Secret, hasPrivateKey bool) ([]byte, []byte, error) { - if _, ok := s.Data["cert"]; ok { - return extractApisixSecretKeyPair(s, hasPrivateKey) - } else if _, ok := s.Data[corev1.TLSCertKey]; ok { - return extractKubeSecretKeyPair(s, hasPrivateKey) - } else if ca, ok := s.Data[corev1.ServiceAccountRootCAKey]; ok && !hasPrivateKey { - return ca, nil, nil - } else { - return nil, nil, errors.New("unknown secret format") - } -} - -func extractApisixSecretKeyPair(s *corev1.Secret, hasPrivateKey bool) (cert []byte, key []byte, err error) { - var ok bool - cert, ok = s.Data["cert"] - if !ok { - return nil, nil, errors.New("missing cert field") - } - - if hasPrivateKey { - key, ok = s.Data["key"] - if !ok { - return nil, nil, errors.New("missing key field") - } - } - return -} - -func extractKubeSecretKeyPair(s *corev1.Secret, hasPrivateKey bool) (cert []byte, key []byte, err error) { - var ok bool - cert, ok = s.Data[corev1.TLSCertKey] - if !ok { - return nil, nil, errors.New("missing cert field") - } - - if hasPrivateKey { - key, ok = s.Data[corev1.TLSPrivateKeyKey] - if !ok { - return nil, nil, errors.New("missing key field") - } - } - return -} - // fillPluginsFromGatewayProxy fill plugins from GatewayProxy to given plugins func (t *Translator) fillPluginsFromGatewayProxy(plugins adctypes.GlobalRule, gatewayProxy *v1alpha1.GatewayProxy) { if gatewayProxy == nil { diff --git a/internal/adc/translator/ingress.go b/internal/adc/translator/ingress.go index f17b159f..35c7e447 100644 --- a/internal/adc/translator/ingress.go +++ b/internal/adc/translator/ingress.go @@ -30,19 +30,20 @@ import ( "github.com/apache/apisix-ingress-controller/internal/controller/label" "github.com/apache/apisix-ingress-controller/internal/id" "github.com/apache/apisix-ingress-controller/internal/provider" + sslutils "github.com/apache/apisix-ingress-controller/internal/ssl" internaltypes "github.com/apache/apisix-ingress-controller/internal/types" ) func (t *Translator) translateIngressTLS(ingressTLS *networkingv1.IngressTLS, secret *corev1.Secret, labels map[string]string) (*adctypes.SSL, error) { // extract the key pair from the secret - cert, key, err := extractKeyPair(secret, true) + cert, key, err := sslutils.ExtractKeyPair(secret, true) if err != nil { return nil, err } hosts := ingressTLS.Hosts if len(hosts) == 0 { - certHosts, err := extractHost(cert) + certHosts, err := sslutils.ExtractHostsFromCertificate(cert) if err != nil { return nil, err } diff --git a/internal/controller/indexer/indexer.go b/internal/controller/indexer/indexer.go index f94faafc..bcd68217 100644 --- a/internal/controller/indexer/indexer.go +++ b/internal/controller/indexer/indexer.go @@ -48,6 +48,7 @@ const ( IngressClassParametersRef = "ingressClassParametersRef" ConsumerGatewayRef = "consumerGatewayRef" PolicyTargetRefs = "targetRefs" + TLSHostIndexRef = "tlsHostRefs" GatewayClassIndexRef = "gatewayClassRef" ApisixUpstreamRef = "apisixUpstreamRef" PluginConfigIndexRef = "pluginConfigRefs" @@ -122,6 +123,16 @@ func setupGatewayIndexer(mgr ctrl.Manager) error { ); err != nil { return err } + + if err := mgr.GetFieldIndexer().IndexField( + context.Background(), + &gatewayv1.Gateway{}, + TLSHostIndexRef, + GatewayTLSHostIndexFunc, + ); err != nil { + return err + } + return nil } @@ -409,6 +420,15 @@ func setupIngressIndexer(mgr ctrl.Manager) error { return err } + if err := mgr.GetFieldIndexer().IndexField( + context.Background(), + &networkingv1.Ingress{}, + TLSHostIndexRef, + IngressTLSHostIndexFunc, + ); err != nil { + return err + } + return nil } @@ -811,6 +831,15 @@ func setupApisixTlsIndexer(mgr ctrl.Manager) error { return err } + if err := mgr.GetFieldIndexer().IndexField( + context.Background(), + &apiv2.ApisixTls{}, + TLSHostIndexRef, + ApisixTlsHostIndexFunc, + ); err != nil { + return err + } + return nil } diff --git a/internal/controller/indexer/ssl_host.go b/internal/controller/indexer/ssl_host.go new file mode 100644 index 00000000..9838d43f --- /dev/null +++ b/internal/controller/indexer/ssl_host.go @@ -0,0 +1,143 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package indexer + +import ( + "sort" + + networkingv1 "k8s.io/api/networking/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + apiv2 "github.com/apache/apisix-ingress-controller/api/v2" + sslutil "github.com/apache/apisix-ingress-controller/internal/ssl" +) + +var ( + tlsHostIndexLogger = ctrl.Log.WithName("tls-host-indexer") + // Empty host is used to match the resource which does not specify any explicit host. + emptyHost = "" +) + +// GatewayTLSHostIndexFunc indexes Gateways by their TLS SNI hosts. +func GatewayTLSHostIndexFunc(rawObj client.Object) []string { + gateway, ok := rawObj.(*gatewayv1.Gateway) + if !ok { + return nil + } + if len(gateway.Spec.Listeners) == 0 { + return nil + } + + hosts := make(map[string]struct{}) + + for _, listener := range gateway.Spec.Listeners { + if listener.TLS == nil || len(listener.TLS.CertificateRefs) == 0 { + continue + } + + hasExplicitHost := false + if listener.Hostname != nil { + candidates := sslutil.NormalizeHosts([]string{string(*listener.Hostname)}) + for _, host := range candidates { + if host == "" { + continue + } + hasExplicitHost = true + hosts[host] = struct{}{} + } + } + + if !hasExplicitHost { + hosts[emptyHost] = struct{}{} + } + } + + tlsHostIndexLogger.Info("GatewayTLSHostIndexFunc", "hosts", hostSetToSlice(hosts), "len", len(hostSetToSlice(hosts))) + + return hostSetToSlice(hosts) +} + +// IngressTLSHostIndexFunc indexes Ingresses by their TLS SNI hosts. +func IngressTLSHostIndexFunc(rawObj client.Object) []string { + ingress, ok := rawObj.(*networkingv1.Ingress) + if !ok { + return nil + } + if len(ingress.Spec.TLS) == 0 { + return nil + } + + hosts := make(map[string]struct{}) + for _, tls := range ingress.Spec.TLS { + if tls.SecretName == "" { + continue + } + + hasExplicitHost := false + candidates := sslutil.NormalizeHosts(tls.Hosts) + for _, host := range candidates { + if host == "" { + continue + } + hasExplicitHost = true + hosts[host] = struct{}{} + } + + if !hasExplicitHost { + hosts[emptyHost] = struct{}{} + } + } + + return hostSetToSlice(hosts) +} + +// ApisixTlsHostIndexFunc indexes ApisixTls resources by their declared TLS hosts. +func ApisixTlsHostIndexFunc(rawObj client.Object) []string { + tls, ok := rawObj.(*apiv2.ApisixTls) + if !ok { + return nil + } + if len(tls.Spec.Hosts) == 0 { + return nil + } + + hostSet := make(map[string]struct{}, len(tls.Spec.Hosts)) + for _, host := range tls.Spec.Hosts { + for _, normalized := range sslutil.NormalizeHosts([]string{string(host)}) { + if normalized == "" { + continue + } + hostSet[normalized] = struct{}{} + } + } + return hostSetToSlice(hostSet) +} + +func hostSetToSlice(set map[string]struct{}) []string { + if len(set) == 0 { + return nil + } + result := make([]string, 0, len(set)) + for host := range set { + result = append(result, host) + } + sort.Strings(result) + return result +} diff --git a/internal/ssl/util.go b/internal/ssl/util.go new file mode 100644 index 00000000..f5fc6b19 --- /dev/null +++ b/internal/ssl/util.go @@ -0,0 +1,142 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ssl + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "strings" + + corev1 "k8s.io/api/core/v1" +) + +var ( + // ErrUnknownSecretFormat indicates the secret does not contain supported TLS data keys. + ErrUnknownSecretFormat = errors.New("unknown secret format") + // ErrMissingCert indicates the secret is missing the certificate part. + ErrMissingCert = errors.New("missing cert field") + // ErrMissingKey indicates the secret is missing the private key part when it is required. + ErrMissingKey = errors.New("missing key field") + // ErrInvalidPEM is returned when the provided certificate is not valid PEM encoded data. + ErrInvalidPEM = errors.New("certificate is not valid PEM data") +) + +// ExtractKeyPair extracts the certificate and, optionally, the private key from a Secret. +// +// Supported formats: +// 1. APISIX style: data keys `cert` and `key` +// 2. Kubernetes TLS secret: data keys `tls.crt` and `tls.key` +// 3. Kubernetes CA secret: data key `ca.crt` (without private key) +func ExtractKeyPair(secret *corev1.Secret, includePrivateKey bool) ([]byte, []byte, error) { + if secret == nil { + return nil, nil, ErrMissingCert + } + + if cert, ok := secret.Data["cert"]; ok { + if includePrivateKey { + key, ok := secret.Data["key"] + if !ok { + return nil, nil, ErrMissingKey + } + return cert, key, nil + } + return cert, nil, nil + } + + if cert, ok := secret.Data[corev1.TLSCertKey]; ok { + if includePrivateKey { + key, ok := secret.Data[corev1.TLSPrivateKeyKey] + if !ok { + return nil, nil, ErrMissingKey + } + return cert, key, nil + } + return cert, nil, nil + } + + if cert, ok := secret.Data[corev1.ServiceAccountRootCAKey]; ok && !includePrivateKey { + return cert, nil, nil + } + + return nil, nil, ErrUnknownSecretFormat +} + +// ExtractCertificate extracts only the certificate data from a Secret. +func ExtractCertificate(secret *corev1.Secret) ([]byte, error) { + cert, _, err := ExtractKeyPair(secret, false) + return cert, err +} + +// ExtractHostsFromCertificate parses the certificate PEM block and returns the DNS names. +func ExtractHostsFromCertificate(certPEM []byte) ([]string, error) { + block, _ := pem.Decode(certPEM) + if block == nil { + return nil, ErrInvalidPEM + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + + hosts := make([]string, 0, len(cert.DNSNames)) + for _, dnsName := range cert.DNSNames { + if dnsName != "*" { + hosts = append(hosts, dnsName) + } + } + return hosts, nil +} + +// NormalizeHosts removes duplicate entries +func NormalizeHosts(hosts []string) []string { + if len(hosts) == 0 { + return nil + } + + normalized := make([]string, 0, len(hosts)) + seen := make(map[string]struct{}, len(hosts)) + for _, host := range hosts { + candidate := strings.ToLower(strings.TrimSpace(host)) + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + normalized = append(normalized, candidate) + } + return normalized +} + +// CertificateHash returns the SHA-256 hash of the leaf certificate contained in the PEM data. +// The hash is calculated from the DER-encoded bytes so that formatting differences (whitespace, +// line endings, certificate ordering) do not affect the result. +func CertificateHash(certPEM []byte) (string, error) { + block, _ := pem.Decode(certPEM) + if block == nil { + return "", ErrInvalidPEM + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", err + } + + sum := sha256.Sum256(cert.Raw) + return hex.EncodeToString(sum[:]), nil +} diff --git a/internal/webhook/v1/apisixtls_webhook.go b/internal/webhook/v1/apisixtls_webhook.go index fe3faf7d..827522ad 100644 --- a/internal/webhook/v1/apisixtls_webhook.go +++ b/internal/webhook/v1/apisixtls_webhook.go @@ -29,6 +29,7 @@ import ( apisixv2 "github.com/apache/apisix-ingress-controller/api/v2" "github.com/apache/apisix-ingress-controller/internal/webhook/v1/reference" + sslvalidator "github.com/apache/apisix-ingress-controller/internal/webhook/v1/ssl" ) var apisixTlsLog = logf.Log.WithName("apisixtls-resource") @@ -63,6 +64,12 @@ func (v *ApisixTlsCustomValidator) ValidateCreate(ctx context.Context, obj runti } apisixTlsLog.Info("Validation for ApisixTls upon creation", "name", tls.GetName(), "namespace", tls.GetNamespace()) + detector := sslvalidator.NewConflictDetector(v.Client) + conflicts := detector.DetectConflicts(ctx, tls) + if len(conflicts) > 0 { + return nil, fmt.Errorf("%s", sslvalidator.FormatConflicts(conflicts)) + } + return v.collectWarnings(ctx, tls), nil } @@ -73,6 +80,12 @@ func (v *ApisixTlsCustomValidator) ValidateUpdate(ctx context.Context, oldObj, n } apisixTlsLog.Info("Validation for ApisixTls upon update", "name", tls.GetName(), "namespace", tls.GetNamespace()) + detector := sslvalidator.NewConflictDetector(v.Client) + conflicts := detector.DetectConflicts(ctx, tls) + if len(conflicts) > 0 { + return nil, fmt.Errorf("%s", sslvalidator.FormatConflicts(conflicts)) + } + return v.collectWarnings(ctx, tls), nil } diff --git a/internal/webhook/v1/gateway_webhook.go b/internal/webhook/v1/gateway_webhook.go index e2c11ff4..788c944f 100644 --- a/internal/webhook/v1/gateway_webhook.go +++ b/internal/webhook/v1/gateway_webhook.go @@ -31,6 +31,11 @@ import ( v1alpha1 "github.com/apache/apisix-ingress-controller/api/v1alpha1" "github.com/apache/apisix-ingress-controller/internal/controller/config" internaltypes "github.com/apache/apisix-ingress-controller/internal/types" +<<<<<<< HEAD +======= + "github.com/apache/apisix-ingress-controller/internal/webhook/v1/reference" + sslvalidator "github.com/apache/apisix-ingress-controller/internal/webhook/v1/ssl" +>>>>>>> 351d20a5 (feat: add certificate conflict detection to admission webhooks (#2603)) ) // nolint:unused @@ -67,6 +72,24 @@ func (v *GatewayCustomValidator) ValidateCreate(ctx context.Context, obj runtime } gatewaylog.Info("Validation for Gateway upon creation", "name", gateway.GetName()) +<<<<<<< HEAD +======= + managed, err := isGatewayManaged(ctx, v.Client, gateway) + if err != nil { + gatewaylog.Error(err, "failed to decide controller ownership", "name", gateway.GetName(), "namespace", gateway.GetNamespace()) + return nil, nil + } + if !managed { + return nil, nil + } + + detector := sslvalidator.NewConflictDetector(v.Client) + conflicts := detector.DetectConflicts(ctx, gateway) + if len(conflicts) > 0 { + return nil, fmt.Errorf("%s", sslvalidator.FormatConflicts(conflicts)) + } + +>>>>>>> 351d20a5 (feat: add certificate conflict detection to admission webhooks (#2603)) warnings := v.warnIfMissingGatewayProxyForGateway(ctx, gateway) return warnings, nil @@ -80,6 +103,24 @@ func (v *GatewayCustomValidator) ValidateUpdate(ctx context.Context, oldObj, new } gatewaylog.Info("Validation for Gateway upon update", "name", gateway.GetName()) +<<<<<<< HEAD +======= + managed, err := isGatewayManaged(ctx, v.Client, gateway) + if err != nil { + gatewaylog.Error(err, "failed to decide controller ownership", "name", gateway.GetName(), "namespace", gateway.GetNamespace()) + return nil, nil + } + if !managed { + return nil, nil + } + + detector := sslvalidator.NewConflictDetector(v.Client) + conflicts := detector.DetectConflicts(ctx, gateway) + if len(conflicts) > 0 { + return nil, fmt.Errorf("%s", sslvalidator.FormatConflicts(conflicts)) + } + +>>>>>>> 351d20a5 (feat: add certificate conflict detection to admission webhooks (#2603)) warnings := v.warnIfMissingGatewayProxyForGateway(ctx, gateway) return warnings, nil diff --git a/internal/webhook/v1/ingress_webhook.go b/internal/webhook/v1/ingress_webhook.go index 777d1e1b..1df7f619 100644 --- a/internal/webhook/v1/ingress_webhook.go +++ b/internal/webhook/v1/ingress_webhook.go @@ -26,6 +26,13 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/webhook" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +<<<<<<< HEAD +======= + + "github.com/apache/apisix-ingress-controller/internal/controller" + "github.com/apache/apisix-ingress-controller/internal/webhook/v1/reference" + sslvalidator "github.com/apache/apisix-ingress-controller/internal/webhook/v1/ssl" +>>>>>>> 351d20a5 (feat: add certificate conflict detection to admission webhooks (#2603)) ) var ingresslog = logf.Log.WithName("ingress-resource") @@ -124,6 +131,12 @@ func (v *IngressCustomValidator) ValidateCreate(_ context.Context, obj runtime.O } ingresslog.Info("Validation for Ingress upon creation", "name", ingress.GetName(), "namespace", ingress.GetNamespace()) + detector := sslvalidator.NewConflictDetector(v.Client) + conflicts := detector.DetectConflicts(ctx, ingress) + if len(conflicts) > 0 { + return nil, fmt.Errorf("%s", sslvalidator.FormatConflicts(conflicts)) + } + // Check for unsupported annotations and generate warnings warnings := checkUnsupportedAnnotations(ingress) @@ -138,9 +151,19 @@ func (v *IngressCustomValidator) ValidateUpdate(_ context.Context, oldObj, newOb } ingresslog.Info("Validation for Ingress upon update", "name", ingress.GetName(), "namespace", ingress.GetNamespace()) + detector := sslvalidator.NewConflictDetector(v.Client) + conflicts := detector.DetectConflicts(ctx, ingress) + if len(conflicts) > 0 { + return nil, fmt.Errorf("%s", sslvalidator.FormatConflicts(conflicts)) + } + // Check for unsupported annotations and generate warnings warnings := checkUnsupportedAnnotations(ingress) +<<<<<<< HEAD +======= + warnings = append(warnings, v.collectReferenceWarnings(ctx, ingress)...) +>>>>>>> 351d20a5 (feat: add certificate conflict detection to admission webhooks (#2603)) return warnings, nil } diff --git a/internal/webhook/v1/ssl/conflict_detector.go b/internal/webhook/v1/ssl/conflict_detector.go new file mode 100644 index 00000000..c39cd274 --- /dev/null +++ b/internal/webhook/v1/ssl/conflict_detector.go @@ -0,0 +1,513 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ssl + +import ( + "context" + "fmt" + "sort" + "strings" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + v1alpha1 "github.com/apache/apisix-ingress-controller/api/v1alpha1" + apiv2 "github.com/apache/apisix-ingress-controller/api/v2" + "github.com/apache/apisix-ingress-controller/internal/controller" + "github.com/apache/apisix-ingress-controller/internal/controller/indexer" + sslutil "github.com/apache/apisix-ingress-controller/internal/ssl" + internaltypes "github.com/apache/apisix-ingress-controller/internal/types" +) + +var logger = log.Log.WithName("ssl-conflict-detector") + +// HostCertMapping represents the relationship between a host and its certificate hash. +type HostCertMapping struct { + Host string + CertificateHash string + ResourceRef string +} + +// SSLConflict exposes the conflict details to the admission webhook for reporting. +type SSLConflict struct { + Host string + ConflictingResource string + CertificateHash string +} + +// ConflictDetector detects SSL conflicts among Gateway, Ingress, and ApisixTls resources. +type ConflictDetector struct { + client client.Client + secretCache map[types.NamespacedName]*secretInfo +} + +type secretInfo struct { + hash string + hosts []string +} + +// NewConflictDetector creates a detector backed by the provided client. +func NewConflictDetector(c client.Client) *ConflictDetector { + return &ConflictDetector{ + client: c, + secretCache: make(map[types.NamespacedName]*secretInfo), + } +} + +// DetectConflicts returns the list of conflicts between the new resource and +// existing resources that are associated with the same GatewayProxy. Best-effort: +// failures while enumerating existing resources or reading Secrets will be logged +// and result in no conflicts instead of blocking the admission. +func (d *ConflictDetector) DetectConflicts(ctx context.Context, obj client.Object) []SSLConflict { + newMappings := d.buildMappingsForObject(ctx, obj) + if len(newMappings) == 0 { + return nil + } + gatewayProxy, err := d.resolveGatewayProxy(ctx, obj) + if err != nil { + logger.Error(err, "failed to resolve GatewayProxy", "object", objectKey(obj)) + return nil + } + if gatewayProxy == nil { + return nil + } + + conflicts := make([]SSLConflict, 0) + + // First, check for conflicts within the new resource itself. + seen := make(map[string]string, len(newMappings)) + for _, mapping := range newMappings { + if mapping.Host == "" || mapping.CertificateHash == "" { + continue + } + if prev, ok := seen[mapping.Host]; ok { + if prev != mapping.CertificateHash { + conflicts = append(conflicts, SSLConflict{ + Host: mapping.Host, + ConflictingResource: mapping.ResourceRef, + CertificateHash: prev, + }) + } + continue + } + seen[mapping.Host] = mapping.CertificateHash + } + + if len(conflicts) > 0 { + return conflicts + } + + externalConflicts, err := d.findExternalConflicts(ctx, obj, gatewayProxy, seen) + if err != nil { + logger.Error(err, "failed to evaluate existing TLS host mappings", "gatewayProxy", objectKey(gatewayProxy)) + return conflicts + } + + conflicts = append(conflicts, externalConflicts...) + return conflicts +} + +// FormatConflicts renders a human-readable error message for multiple conflicts. +func FormatConflicts(conflicts []SSLConflict) string { + if len(conflicts) == 0 { + return "" + } + var sb strings.Builder + sb.WriteString("SSL configuration conflicts detected:") + for _, conflict := range conflicts { + sb.WriteString(fmt.Sprintf("\n- Host '%s' is already configured with a different certificate in %s", conflict.Host, conflict.ConflictingResource)) + } + return sb.String() +} + +// BuildGatewayMappings calculates host-to-certificate mappings for a Gateway. +func (d *ConflictDetector) BuildGatewayMappings(ctx context.Context, gateway *gatewayv1.Gateway) []HostCertMapping { + mappings := make([]HostCertMapping, 0) + + if gateway == nil { + return mappings + } + + for _, listener := range gateway.Spec.Listeners { + if listener.TLS == nil || listener.TLS.CertificateRefs == nil { + continue + } + for _, ref := range listener.TLS.CertificateRefs { + if ref.Kind != nil && *ref.Kind != internaltypes.KindSecret { + continue + } + if ref.Group != nil && string(*ref.Group) != corev1.GroupName { + continue + } + secretNN := types.NamespacedName{ + Namespace: gateway.Namespace, + Name: string(ref.Name), + } + if ref.Namespace != nil && *ref.Namespace != "" { + secretNN.Namespace = string(*ref.Namespace) + } + + info, err := d.getSecretInfo(ctx, secretNN) + if err != nil { + logger.Error(err, "failed to read secret for Gateway", "gateway", objectKey(gateway), "secret", secretNN) + continue + } + + hosts := make([]string, 0, 1) + if listener.Hostname != nil && *listener.Hostname != "" { + hosts = append(hosts, string(*listener.Hostname)) + } + hosts = sslutil.NormalizeHosts(hosts) + if len(hosts) == 0 { + hosts = info.hosts + } + for _, host := range hosts { + mappings = append(mappings, HostCertMapping{ + Host: host, + CertificateHash: info.hash, + ResourceRef: fmt.Sprintf("%s/%s/%s", internaltypes.KindGateway, gateway.Namespace, gateway.Name), + }) + } + } + } + + return mappings +} + +// BuildIngressMappings calculates host-to-certificate mappings for an Ingress. +func (d *ConflictDetector) BuildIngressMappings(ctx context.Context, ingress *networkingv1.Ingress) []HostCertMapping { + mappings := make([]HostCertMapping, 0) + if ingress == nil { + return mappings + } + + for _, tls := range ingress.Spec.TLS { + if tls.SecretName == "" { + continue + } + secretNN := types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName} + info, err := d.getSecretInfo(ctx, secretNN) + if err != nil { + logger.Error(err, "failed to read secret for Ingress", "ingress", objectKey(ingress), "secret", secretNN) + continue + } + + hosts := sslutil.NormalizeHosts(tls.Hosts) + if len(hosts) == 0 { + hosts = info.hosts + } + for _, host := range hosts { + mappings = append(mappings, HostCertMapping{ + Host: host, + CertificateHash: info.hash, + ResourceRef: fmt.Sprintf("%s/%s/%s", internaltypes.KindIngress, ingress.Namespace, ingress.Name), + }) + } + } + + return mappings +} + +// BuildApisixTlsMappings calculates host-to-certificate mappings for an ApisixTls resource. +func (d *ConflictDetector) BuildApisixTlsMappings(ctx context.Context, tls *apiv2.ApisixTls) []HostCertMapping { + mappings := make([]HostCertMapping, 0) + if tls == nil { + return mappings + } + + secretNN := types.NamespacedName{ + Namespace: tls.Spec.Secret.Namespace, + Name: tls.Spec.Secret.Name, + } + info, err := d.getSecretInfo(ctx, secretNN) + if err != nil { + logger.Error(err, "failed to read secret for ApisixTls", "apisixtls", objectKey(tls), "secret", secretNN) + return mappings + } + + hosts := make([]string, 0, len(tls.Spec.Hosts)) + for _, host := range tls.Spec.Hosts { + hosts = append(hosts, string(host)) + } + hosts = sslutil.NormalizeHosts(hosts) + // NOTICE: hosts is required by the CRD, so this should never happen + // if len(hosts) == 0 { + // hosts = info.hosts + // } + for _, host := range hosts { + mappings = append(mappings, HostCertMapping{ + Host: host, + CertificateHash: info.hash, + ResourceRef: fmt.Sprintf("%s/%s/%s", internaltypes.KindApisixTls, tls.Namespace, tls.Name), + }) + } + + return mappings +} + +func (d *ConflictDetector) getSecretInfo(ctx context.Context, nn types.NamespacedName) (*secretInfo, error) { + if nn.Name == "" || nn.Namespace == "" { + return nil, fmt.Errorf("secret namespaced name is incomplete: %s", nn) + } + if info, ok := d.secretCache[nn]; ok { + return info, nil + } + + var secret corev1.Secret + if err := d.client.Get(ctx, nn, &secret); err != nil { + return nil, err + } + + cert, err := sslutil.ExtractCertificate(&secret) + if err != nil { + return nil, err + } + + hash, err := sslutil.CertificateHash(cert) + if err != nil { + return nil, err + } + hosts, err := sslutil.ExtractHostsFromCertificate(cert) + if err != nil { + logger.Error(err, "failed to extract hosts from certificate", "secret", nn) + hosts = nil + } + info := &secretInfo{ + hash: hash, + hosts: sslutil.NormalizeHosts(hosts), + } + d.secretCache[nn] = info + return info, nil +} + +func (d *ConflictDetector) resolveGatewayProxy(ctx context.Context, obj client.Object) (*v1alpha1.GatewayProxy, error) { + switch resource := obj.(type) { + case *gatewayv1.Gateway: + return controller.GetGatewayProxyByGateway(ctx, d.client, resource) + case *networkingv1.Ingress: + ingressClass, err := controller.FindMatchingIngressClass(ctx, d.client, logger, resource) + if err != nil { + return nil, err + } + if ingressClass == nil { + return nil, nil + } + return controller.GetGatewayProxyByIngressClass(ctx, d.client, ingressClass) + case *apiv2.ApisixTls: + ingressClass, err := controller.FindMatchingIngressClass(ctx, d.client, logger, resource) + if err != nil { + return nil, err + } + if ingressClass == nil { + return nil, nil + } + return controller.GetGatewayProxyByIngressClass(ctx, d.client, ingressClass) + default: + return nil, fmt.Errorf("unsupported object type %T", obj) + } +} + +func (d *ConflictDetector) findExternalConflicts(ctx context.Context, obj client.Object, gatewayProxy *v1alpha1.GatewayProxy, hosts map[string]string) ([]SSLConflict, error) { + excludeUID := obj.GetUID() + hostValues := make([]string, 0, len(hosts)) + for host := range hosts { + hostValues = append(hostValues, host) + } + sort.Strings(hostValues) + + conflictSet := make(map[string]SSLConflict) + proxyCache := make(map[types.UID]*v1alpha1.GatewayProxy) + mappingCache := make(map[types.UID][]HostCertMapping) + + var noHostCandidates []client.Object + noHostFetched := false + + for _, host := range hostValues { + candidates, err := d.listResourcesByHost(ctx, host) + if err != nil { + logger.Error(err, "failed to list resources by host", "host", host) + return nil, err + } + if host != "" { + if !noHostFetched { + // List resources with empty host. + noHostCandidates, err = d.listResourcesByHost(ctx, "") + if err != nil { + logger.Error(err, "failed to list resources by host", "host", "", "object", objectKey(obj)) + return nil, err + } + noHostFetched = true + } + candidates = mergeCandidateObjects(candidates, noHostCandidates) + } + for _, candidate := range candidates { + if candidate.GetUID() == excludeUID { + continue + } + + resolvedProxy, err := d.resolveGatewayProxyWithCache(ctx, candidate, proxyCache) + if err != nil { + logger.Error(err, "failed to resolve GatewayProxy for indexed resource", "resource", objectKey(candidate), "host", host) + continue + } + // we only check if the resolved proxy is the same as the gateway proxy, + if resolvedProxy == nil || !gatewayProxiesEqual(resolvedProxy, gatewayProxy) { + continue + } + + mapping, ok := d.mappingForHostWithCache(ctx, candidate, host, mappingCache) + if !ok { + continue + } + // same cert hash, no conflict + if mapping.CertificateHash == hosts[host] { + continue + } + + key := fmt.Sprintf("%s|%s|%s", host, mapping.ResourceRef, mapping.CertificateHash) + if _, exists := conflictSet[key]; exists { + continue + } + conflictSet[key] = SSLConflict{ + Host: host, + ConflictingResource: mapping.ResourceRef, + CertificateHash: mapping.CertificateHash, + } + } + } + + if len(conflictSet) == 0 { + return nil, nil + } + + keys := make([]string, 0, len(conflictSet)) + for key := range conflictSet { + keys = append(keys, key) + } + sort.Strings(keys) + + results := make([]SSLConflict, 0, len(keys)) + for _, key := range keys { + results = append(results, conflictSet[key]) + } + return results, nil +} + +func (d *ConflictDetector) listResourcesByHost(ctx context.Context, host string) ([]client.Object, error) { + results := make([]client.Object, 0) + + var gatewayList gatewayv1.GatewayList + if err := d.client.List(ctx, &gatewayList, client.MatchingFields{indexer.TLSHostIndexRef: host}); err != nil { + return nil, err + } + for i := range gatewayList.Items { + results = append(results, gatewayList.Items[i].DeepCopy()) + } + + var ingressList networkingv1.IngressList + if err := d.client.List(ctx, &ingressList, client.MatchingFields{indexer.TLSHostIndexRef: host}); err != nil { + return nil, err + } + for i := range ingressList.Items { + results = append(results, ingressList.Items[i].DeepCopy()) + } + + var tlsList apiv2.ApisixTlsList + if err := d.client.List(ctx, &tlsList, client.MatchingFields{indexer.TLSHostIndexRef: host}); err != nil { + return nil, err + } + for i := range tlsList.Items { + results = append(results, tlsList.Items[i].DeepCopy()) + } + + return results, nil +} + +func mergeCandidateObjects(primary, additional []client.Object) []client.Object { + if len(additional) == 0 { + return primary + } + seen := make(map[types.UID]struct{}, len(primary)) + for _, obj := range primary { + seen[obj.GetUID()] = struct{}{} + } + for _, obj := range additional { + if _, exists := seen[obj.GetUID()]; exists { + continue + } + primary = append(primary, obj) + seen[obj.GetUID()] = struct{}{} + } + return primary +} + +func (d *ConflictDetector) resolveGatewayProxyWithCache(ctx context.Context, obj client.Object, cache map[types.UID]*v1alpha1.GatewayProxy) (*v1alpha1.GatewayProxy, error) { + if proxy, ok := cache[obj.GetUID()]; ok { + return proxy, nil + } + proxy, err := d.resolveGatewayProxy(ctx, obj) + if err != nil { + return nil, err + } + cache[obj.GetUID()] = proxy + return proxy, nil +} + +func (d *ConflictDetector) mappingForHostWithCache(ctx context.Context, obj client.Object, host string, cache map[types.UID][]HostCertMapping) (HostCertMapping, bool) { + mappings, ok := cache[obj.GetUID()] + if !ok { + mappings = d.buildMappingsForObject(ctx, obj) + cache[obj.GetUID()] = mappings + } + + for _, mapping := range mappings { + if mapping.Host == host { + return mapping, true + } + } + return HostCertMapping{}, false +} + +func (d *ConflictDetector) buildMappingsForObject(ctx context.Context, obj client.Object) []HostCertMapping { + switch resource := obj.(type) { + case *gatewayv1.Gateway: + return d.BuildGatewayMappings(ctx, resource) + case *networkingv1.Ingress: + return d.BuildIngressMappings(ctx, resource) + case *apiv2.ApisixTls: + return d.BuildApisixTlsMappings(ctx, resource) + default: + return nil + } +} + +func gatewayProxiesEqual(a, b *v1alpha1.GatewayProxy) bool { + if a == nil || b == nil { + return false + } + return a.Namespace == b.Namespace && a.Name == b.Name +} + +func objectKey(obj client.Object) types.NamespacedName { + if obj == nil { + return types.NamespacedName{} + } + return types.NamespacedName{Namespace: obj.GetNamespace(), Name: obj.GetName()} +} diff --git a/internal/webhook/v1/ssl/conflict_detector_test.go b/internal/webhook/v1/ssl/conflict_detector_test.go new file mode 100644 index 00000000..d9d3063f --- /dev/null +++ b/internal/webhook/v1/ssl/conflict_detector_test.go @@ -0,0 +1,418 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ssl + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + v1alpha1 "github.com/apache/apisix-ingress-controller/api/v1alpha1" + apiv2 "github.com/apache/apisix-ingress-controller/api/v2" + "github.com/apache/apisix-ingress-controller/internal/controller/config" + "github.com/apache/apisix-ingress-controller/internal/controller/indexer" + internaltypes "github.com/apache/apisix-ingress-controller/internal/types" +) + +const ( + testNamespace = "default" + testIngressClass = "example-class" +) + +func TestConflictDetectorDetectsGatewayConflict(t *testing.T) { + scheme := buildScheme(t) + secretA := newTLSSecret(t, "cert-a", []string{"example.com"}) + secretB := newTLSSecret(t, "cert-b", []string{"example.com"}) + + gatewayProxy := &v1alpha1.GatewayProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo-gp", + Namespace: testNamespace, + UID: "gatewayproxy-uid", + }, + } + + modeTerminate := gatewayv1.TLSModeTerminate + hostname := gatewayv1.Hostname("example.com") + gateway := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo-gateway", + Namespace: testNamespace, + UID: "gateway-uid", + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: gatewayv1.ObjectName("demo-gc"), + Listeners: []gatewayv1.Listener{ + { + Name: "tls", + Protocol: gatewayv1.HTTPSProtocolType, + Port: 443, + Hostname: &hostname, + TLS: &gatewayv1.GatewayTLSConfig{ + Mode: &modeTerminate, + CertificateRefs: []gatewayv1.SecretObjectReference{ + {Name: gatewayv1.ObjectName(secretA.Name)}, + }, + }, + }, + }, + }, + } + gateway.Spec.Infrastructure = &gatewayv1.GatewayInfrastructure{ + ParametersRef: &gatewayv1.LocalParametersReference{ + Group: gatewayv1.Group(v1alpha1.GroupVersion.Group), + Kind: gatewayv1.Kind(internaltypes.KindGatewayProxy), + Name: gatewayProxy.Name, + }, + } + + ingressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testIngressClass, + }, + Spec: networkingv1.IngressClassSpec{ + Controller: config.ControllerConfig.ControllerName, + Parameters: &networkingv1.IngressClassParametersReference{ + APIGroup: ptr.To(v1alpha1.GroupVersion.Group), + Kind: internaltypes.KindGatewayProxy, + Name: gatewayProxy.Name, + Namespace: ptr.To(testNamespace), + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithIndex(&gatewayv1.Gateway{}, indexer.ParametersRef, indexer.GatewayParametersRefIndexFunc). + WithIndex(&gatewayv1.Gateway{}, indexer.TLSHostIndexRef, indexer.GatewayTLSHostIndexFunc). + WithIndex(&networkingv1.IngressClass{}, indexer.IngressClassParametersRef, indexer.IngressClassParametersRefIndexFunc). + WithIndex(&networkingv1.Ingress{}, indexer.IngressClassRef, indexer.IngressClassRefIndexFunc). + WithIndex(&networkingv1.Ingress{}, indexer.TLSHostIndexRef, indexer.IngressTLSHostIndexFunc). + WithIndex(&apiv2.ApisixTls{}, indexer.IngressClassRef, indexer.ApisixTlsIngressClassIndexFunc). + WithIndex(&apiv2.ApisixTls{}, indexer.TLSHostIndexRef, indexer.ApisixTlsHostIndexFunc). + WithObjects(secretA, secretB, gatewayProxy, gateway, ingressClass). + Build() + + detector := NewConflictDetector(fakeClient) + ctx := context.Background() + + newTls := &apiv2.ApisixTls{ + ObjectMeta: metav1.ObjectMeta{ + Name: "incoming", + Namespace: testNamespace, + UID: "apisixtls-uid", + }, + Spec: apiv2.ApisixTlsSpec{ + IngressClassName: testIngressClass, + Hosts: []apiv2.HostType{"example.com"}, + Secret: apiv2.ApisixSecret{ + Name: secretB.Name, + Namespace: secretB.Namespace, + }, + }, + } + + conflicts := detector.DetectConflicts(ctx, newTls) + if len(conflicts) != 1 { + t.Fatalf("expected 1 conflict, got %d", len(conflicts)) + } + conflict := conflicts[0] + if conflict.Host != "example.com" { + t.Fatalf("unexpected host: %s", conflict.Host) + } + expectedRef := fmt.Sprintf("Gateway/%s/%s", gateway.Namespace, gateway.Name) + if conflict.ConflictingResource != expectedRef { + t.Fatalf("unexpected conflicting resource: %s", conflict.ConflictingResource) + } +} + +func TestConflictDetectorAllowedWhenCertificateMatches(t *testing.T) { + scheme := buildScheme(t) + secret := newTLSSecret(t, "shared-cert", []string{"shared.example.com"}) + + gatewayProxy := &v1alpha1.GatewayProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gp", + Namespace: testNamespace, + UID: "gatewayproxy-uid-2", + }, + } + modeTerminate := gatewayv1.TLSModeTerminate + listenerHostname := gatewayv1.Hostname("shared.example.com") + gateway := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw", + Namespace: testNamespace, + UID: "gateway-uid-2", + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: gatewayv1.ObjectName("gc"), + Listeners: []gatewayv1.Listener{ + { + Name: "tls", + Protocol: gatewayv1.HTTPSProtocolType, + Port: 443, + Hostname: &listenerHostname, + TLS: &gatewayv1.GatewayTLSConfig{ + Mode: &modeTerminate, + CertificateRefs: []gatewayv1.SecretObjectReference{{Name: gatewayv1.ObjectName(secret.Name)}}, + }, + }, + }, + }, + } + gateway.Spec.Infrastructure = &gatewayv1.GatewayInfrastructure{ + ParametersRef: &gatewayv1.LocalParametersReference{ + Group: gatewayv1.Group(v1alpha1.GroupVersion.Group), + Kind: gatewayv1.Kind(internaltypes.KindGatewayProxy), + Name: gatewayProxy.Name, + }, + } + + ingressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{Name: testIngressClass}, + Spec: networkingv1.IngressClassSpec{ + Controller: config.ControllerConfig.ControllerName, + Parameters: &networkingv1.IngressClassParametersReference{ + APIGroup: ptr.To(v1alpha1.GroupVersion.Group), + Kind: internaltypes.KindGatewayProxy, + Name: gatewayProxy.Name, + Namespace: ptr.To(testNamespace), + }, + }, + } + + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithIndex(&gatewayv1.Gateway{}, indexer.ParametersRef, indexer.GatewayParametersRefIndexFunc). + WithIndex(&gatewayv1.Gateway{}, indexer.TLSHostIndexRef, indexer.GatewayTLSHostIndexFunc). + WithIndex(&networkingv1.IngressClass{}, indexer.IngressClassParametersRef, indexer.IngressClassParametersRefIndexFunc). + WithIndex(&networkingv1.Ingress{}, indexer.IngressClassRef, indexer.IngressClassRefIndexFunc). + WithIndex(&networkingv1.Ingress{}, indexer.TLSHostIndexRef, indexer.IngressTLSHostIndexFunc). + WithIndex(&apiv2.ApisixTls{}, indexer.IngressClassRef, indexer.ApisixTlsIngressClassIndexFunc). + WithIndex(&apiv2.ApisixTls{}, indexer.TLSHostIndexRef, indexer.ApisixTlsHostIndexFunc). + WithObjects(secret, gatewayProxy, gateway, ingressClass). + Build() + + detector := NewConflictDetector(client) + ctx := context.Background() + + newTls := &apiv2.ApisixTls{ + ObjectMeta: metav1.ObjectMeta{ + Name: "allowed", + Namespace: testNamespace, + UID: "apisixtls-uid-2", + }, + Spec: apiv2.ApisixTlsSpec{ + IngressClassName: testIngressClass, + Hosts: []apiv2.HostType{"shared.example.com"}, + Secret: apiv2.ApisixSecret{Name: secret.Name, Namespace: secret.Namespace}, + }, + } + + conflicts := detector.DetectConflicts(ctx, newTls) + if len(conflicts) != 0 { + t.Fatalf("expected no conflicts, got %v", conflicts) + } +} + +func buildScheme(t *testing.T) *runtime.Scheme { + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{ + corev1.AddToScheme, + networkingv1.AddToScheme, + gatewayv1.Install, + apiv2.AddToScheme, + v1alpha1.AddToScheme, + } { + if err := add(scheme); err != nil { + t.Fatalf("failed to add to scheme: %v", err) + } + } + return scheme +} + +func newTLSSecret(t *testing.T, name string, hosts []string) *corev1.Secret { + cert, key := generateCertificate(t, hosts) + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: testNamespace, + }, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: cert, + corev1.TLSPrivateKeyKey: key, + }, + } +} + +func TestConflictDetectorDetectsSelfConflict(t *testing.T) { + scheme := buildScheme(t) + secretA := newTLSSecret(t, "cert-a", []string{"example.com"}) + secretB := newTLSSecret(t, "cert-b", []string{"example.com"}) + + gatewayProxy := &v1alpha1.GatewayProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo-gp", + Namespace: testNamespace, + UID: "gatewayproxy-uid-3", + }, + } + + modeTerminate := gatewayv1.TLSModeTerminate + hostname := gatewayv1.Hostname("example.com") + // Create a Gateway with TWO listeners using DIFFERENT certificates for the SAME host + gateway := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo-gateway", + Namespace: testNamespace, + UID: "gateway-uid-3", + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: gatewayv1.ObjectName("demo-gc"), + Listeners: []gatewayv1.Listener{ + { + Name: "tls-1", + Protocol: gatewayv1.HTTPSProtocolType, + Port: 443, + Hostname: &hostname, + TLS: &gatewayv1.GatewayTLSConfig{ + Mode: &modeTerminate, + CertificateRefs: []gatewayv1.SecretObjectReference{ + {Name: gatewayv1.ObjectName(secretA.Name)}, + }, + }, + }, + { + Name: "tls-2", + Protocol: gatewayv1.HTTPSProtocolType, + Port: 8443, + Hostname: &hostname, + TLS: &gatewayv1.GatewayTLSConfig{ + Mode: &modeTerminate, + CertificateRefs: []gatewayv1.SecretObjectReference{ + {Name: gatewayv1.ObjectName(secretB.Name)}, + }, + }, + }, + }, + }, + } + gateway.Spec.Infrastructure = &gatewayv1.GatewayInfrastructure{ + ParametersRef: &gatewayv1.LocalParametersReference{ + Group: gatewayv1.Group(v1alpha1.GroupVersion.Group), + Kind: gatewayv1.Kind(internaltypes.KindGatewayProxy), + Name: gatewayProxy.Name, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithIndex(&gatewayv1.Gateway{}, indexer.ParametersRef, indexer.GatewayParametersRefIndexFunc). + WithIndex(&gatewayv1.Gateway{}, indexer.TLSHostIndexRef, indexer.GatewayTLSHostIndexFunc). + WithIndex(&networkingv1.IngressClass{}, indexer.IngressClassParametersRef, indexer.IngressClassParametersRefIndexFunc). + WithIndex(&networkingv1.Ingress{}, indexer.IngressClassRef, indexer.IngressClassRefIndexFunc). + WithIndex(&networkingv1.Ingress{}, indexer.TLSHostIndexRef, indexer.IngressTLSHostIndexFunc). + WithIndex(&apiv2.ApisixTls{}, indexer.IngressClassRef, indexer.ApisixTlsIngressClassIndexFunc). + WithIndex(&apiv2.ApisixTls{}, indexer.TLSHostIndexRef, indexer.ApisixTlsHostIndexFunc). + WithObjects(secretA, secretB, gatewayProxy, gateway). + Build() + + detector := NewConflictDetector(fakeClient) + ctx := context.Background() + + // Build mappings for this Gateway - should have 2 mappings for same host with different certs + mappings := detector.BuildGatewayMappings(ctx, gateway) + if len(mappings) != 2 { + t.Fatalf("expected 2 mappings, got %d", len(mappings)) + } + + // Both mappings should be for the same host + if mappings[0].Host != mappings[1].Host { + t.Fatalf("expected same host, got %s and %s", mappings[0].Host, mappings[1].Host) + } + + // But with different certificate hashes + if mappings[0].CertificateHash == mappings[1].CertificateHash { + t.Fatalf("expected different certificate hashes, but they are the same: %s", mappings[0].CertificateHash) + } + + // DetectConflicts should detect this self-conflict + conflicts := detector.DetectConflicts(ctx, gateway) + + // Should detect 1 conflict (the resource conflicts with itself) + if len(conflicts) != 1 { + t.Fatalf("expected 1 self-conflict, got %d", len(conflicts)) + } + + conflict := conflicts[0] + if conflict.Host != "example.com" { + t.Fatalf("unexpected host: %s", conflict.Host) + } + + // The conflicting resource should point to itself + expectedRef := fmt.Sprintf("Gateway/%s/%s", gateway.Namespace, gateway.Name) + if conflict.ConflictingResource != expectedRef { + t.Fatalf("unexpected conflicting resource: %s, expected %s", conflict.ConflictingResource, expectedRef) + } +} + +func generateCertificate(t *testing.T, hosts []string) ([]byte, []byte) { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("failed to generate private key: %v", err) + } + serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) + if err != nil { + t.Fatalf("failed to generate serial: %v", err) + } + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: hosts[0], + }, + DNSNames: hosts, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("failed to create certificate: %v", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + return certPEM, keyPEM +} diff --git a/test/e2e/ingress/ssl_conflict.go b/test/e2e/ingress/ssl_conflict.go new file mode 100644 index 00000000..8c0ff4bb --- /dev/null +++ b/test/e2e/ingress/ssl_conflict.go @@ -0,0 +1,1215 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package webhook + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" +) + +var _ = Describe("Test SSL/TLS Conflict Detection", Label("webhook"), func() { + s := scaffold.NewScaffold(scaffold.Options{ + Name: "ssl-conflict-test", + EnableWebhook: true, + }) + + BeforeEach(func() { + By("creating GatewayProxy") + err := s.CreateResourceFromString(s.GetGatewayProxySpec()) + Expect(err).NotTo(HaveOccurred(), "creating GatewayProxy") + time.Sleep(5 * time.Second) + + By("creating GatewayClass") + err = s.CreateResourceFromString(s.GetGatewayClassYaml()) + Expect(err).NotTo(HaveOccurred(), "creating GatewayClass") + time.Sleep(2 * time.Second) + + By("creating IngressClass") + err = s.CreateResourceFromStringWithNamespace(s.GetIngressClassYaml(), "") + Expect(err).NotTo(HaveOccurred(), "creating IngressClass") + time.Sleep(2 * time.Second) + }) + + Context("ApisixTls conflict detection", func() { + It("should reject ApisixTls with conflicting certificate for same host", func() { + host := "conflict.example.com" + secretA := "tls-cert-a" + secretB := "tls-cert-b" + + By("creating two different TLS secrets") + createApisixTLSSecret(s, secretA, host, "creating secret A") + createApisixTLSSecret(s, secretB, host, "creating secret B") + time.Sleep(2 * time.Second) + + By("creating first ApisixTls with certificate A") + tlsAYAML := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: tls-a + namespace: %s +spec: + ingressClassName: %s + hosts: + - %s + secret: + name: %s + namespace: %s +`, s.Namespace(), s.Namespace(), host, secretA, s.Namespace()) + err := s.CreateResourceFromString(tlsAYAML) + Expect(err).NotTo(HaveOccurred(), "creating ApisixTls A") + + time.Sleep(2 * time.Second) + + By("attempting to create second ApisixTls with certificate B for same host") + tlsBYAML := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: tls-b + namespace: %s +spec: + ingressClassName: %s + hosts: + - %s + secret: + name: %s + namespace: %s +`, s.Namespace(), s.Namespace(), host, secretB, s.Namespace()) + err = s.CreateResourceFromString(tlsBYAML) + Expect(err).Should(HaveOccurred(), "expecting conflict when creating ApisixTls B") + Expect(err.Error()).To(ContainSubstring("SSL configuration conflicts detected")) + Expect(err.Error()).To(ContainSubstring(host)) + Expect(err.Error()).To(ContainSubstring("ApisixTls")) + }) + + It("should allow ApisixTls with same certificate for same host", func() { + host := "shared.example.com" + sharedSecret := "tls-shared-cert" + + By("creating a shared TLS secret") + createKubeTLSSecret(s, sharedSecret, host, "creating shared secret") + + time.Sleep(2 * time.Second) + + By("creating first ApisixTls with shared certificate") + tls1YAML := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: tls-shared-1 + namespace: %s +spec: + ingressClassName: %s + hosts: + - %s + secret: + name: %s + namespace: %s +`, s.Namespace(), s.Namespace(), host, sharedSecret, s.Namespace()) + err := s.CreateResourceFromString(tls1YAML) + Expect(err).NotTo(HaveOccurred(), "creating first ApisixTls") + + time.Sleep(2 * time.Second) + + By("creating second ApisixTls with same certificate for same host") + tls2YAML := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: tls-shared-2 + namespace: %s +spec: + ingressClassName: %s + hosts: + - %s + secret: + name: %s + namespace: %s +`, s.Namespace(), s.Namespace(), host, sharedSecret, s.Namespace()) + err = s.CreateResourceFromString(tls2YAML) + Expect(err).NotTo(HaveOccurred(), "second ApisixTls should be allowed with same certificate") + }) + }) + + Context("Gateway and ApisixTls conflict detection", func() { + It("should reject Gateway with conflicting certificate against existing ApisixTls", func() { + host := "gateway-vs-tls.example.com" + secretA := "gateway-cert-a" + secretB := "gateway-cert-b" + + By("creating two different TLS secrets") + createKubeTLSSecret(s, secretA, host, "creating secret A") + createKubeTLSSecret(s, secretB, host, "creating secret B") + + time.Sleep(2 * time.Second) + + By("creating ApisixTls with certificate A") + tlsYAML := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: apisixtls-first + namespace: %s +spec: + ingressClassName: %s + hosts: + - %s + secret: + name: %s + namespace: %s +`, s.Namespace(), s.Namespace(), host, secretA, s.Namespace()) + err := s.CreateResourceFromString(tlsYAML) + Expect(err).NotTo(HaveOccurred(), "creating ApisixTls") + + time.Sleep(2 * time.Second) + + By("attempting to create Gateway with certificate B for same host") + hostname := host + gatewayYAML := fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-conflict + namespace: %s +spec: + gatewayClassName: %s + listeners: + - name: https + protocol: HTTPS + port: 443 + hostname: %s + tls: + mode: Terminate + certificateRefs: + - name: %s + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +`, s.Namespace(), s.Namespace(), hostname, secretB) + err = s.CreateResourceFromString(gatewayYAML) + Expect(err).Should(HaveOccurred(), "expecting conflict when creating Gateway") + Expect(err.Error()).To(ContainSubstring("SSL configuration conflicts detected")) + Expect(err.Error()).To(ContainSubstring(host)) + }) + + It("should allow Gateway with same certificate as existing ApisixTls", func() { + host := "gateway-tls-allowed.example.com" + sharedSecret := "gateway-shared-cert" + + By("creating a shared TLS secret") + createKubeTLSSecret(s, sharedSecret, host, "creating shared secret") + + time.Sleep(2 * time.Second) + + By("creating ApisixTls with shared certificate") + tlsYAML := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: apisixtls-allowed + namespace: %s +spec: + ingressClassName: %s + hosts: + - %s + secret: + name: %s + namespace: %s +`, s.Namespace(), s.Namespace(), host, sharedSecret, s.Namespace()) + err := s.CreateResourceFromString(tlsYAML) + Expect(err).NotTo(HaveOccurred(), "creating ApisixTls") + + time.Sleep(2 * time.Second) + + By("creating Gateway with same certificate") + hostname := host + gatewayYAML := fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-allowed + namespace: %s +spec: + gatewayClassName: %s + listeners: + - name: https + protocol: HTTPS + port: 443 + hostname: %s + tls: + mode: Terminate + certificateRefs: + - name: %s + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +`, s.Namespace(), s.Namespace(), hostname, sharedSecret) + err = s.CreateResourceFromString(gatewayYAML) + Expect(err).NotTo(HaveOccurred(), "Gateway should be allowed with same certificate") + }) + + It("should reject ApisixTls when Gateway without hostname uses different certificate", func() { + host := "gateway-no-host-conflict.example.com" + secretA := "gateway-no-host-cert-a" + secretB := "gateway-no-host-cert-b" + + By("creating two different TLS secrets") + createKubeTLSSecret(s, secretA, host, "creating secret A") + createKubeTLSSecret(s, secretB, host, "creating secret B") + + time.Sleep(2 * time.Second) + + By("creating Gateway without explicit hostname using certificate A") + gatewayYAML := fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-no-host + namespace: %s +spec: + gatewayClassName: %s + listeners: + - name: https + protocol: HTTPS + port: 443 + tls: + mode: Terminate + certificateRefs: + - name: %s + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +`, s.Namespace(), s.Namespace(), secretA) + err := s.CreateResourceFromString(gatewayYAML) + Expect(err).NotTo(HaveOccurred(), "creating Gateway without hostname") + + time.Sleep(2 * time.Second) + + By("attempting to create ApisixTls with certificate B for same host") + tlsYAML := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: apisixtls-no-host-conflict + namespace: %s +spec: + ingressClassName: %s + hosts: + - %s + secret: + name: %s + namespace: %s +`, s.Namespace(), s.Namespace(), host, secretB, s.Namespace()) + err = s.CreateResourceFromString(tlsYAML) + Expect(err).Should(HaveOccurred(), "expecting conflict when creating ApisixTls without hostname on existing Gateway") + Expect(err.Error()).To(ContainSubstring("SSL configuration conflicts detected")) + Expect(err.Error()).To(ContainSubstring(host)) + }) + }) + + Context("Gateway self-conflict detection", func() { + It("should reject Gateway with multiple listeners using different certificates for same host", func() { + host := "self-conflict.example.com" + secretA := "gateway-self-cert-a" + secretB := "gateway-self-cert-b" + + By("creating two different TLS secrets") + createKubeTLSSecret(s, secretA, host, "creating secret A") + createKubeTLSSecret(s, secretB, host, "creating secret B") + + time.Sleep(2 * time.Second) + + By("attempting to create Gateway with two listeners using different certificates for same host") + hostname := host + gatewayYAML := fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-self-conflict + namespace: %s +spec: + gatewayClassName: %s + listeners: + - name: https-1 + protocol: HTTPS + port: 443 + hostname: %s + tls: + mode: Terminate + certificateRefs: + - name: %s + - name: https-2 + protocol: HTTPS + port: 8443 + hostname: %s + tls: + mode: Terminate + certificateRefs: + - name: %s + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +`, s.Namespace(), s.Namespace(), hostname, secretA, hostname, secretB) + err := s.CreateResourceFromString(gatewayYAML) + Expect(err).Should(HaveOccurred(), "expecting self-conflict in Gateway") + Expect(err.Error()).To(ContainSubstring("SSL configuration conflicts detected")) + Expect(err.Error()).To(ContainSubstring(host)) + }) + }) + + Context("Ingress conflict detection", func() { + It("should reject Ingress with conflicting certificate in its own TLS config", func() { + host := "ingress-self-conflict.example.com" + secretA := "ingress-self-cert-a" + secretB := "ingress-self-cert-b" + + By("creating two different TLS secrets") + createKubeTLSSecret(s, secretA, host, "creating secret A") + createKubeTLSSecret(s, secretB, host, "creating secret B") + + time.Sleep(2 * time.Second) + + By("creating a backend service for Ingress") + serviceYAML := fmt.Sprintf(` +apiVersion: v1 +kind: Service +metadata: + name: test-service-self + namespace: %s +spec: + selector: + app: test + ports: + - port: 80 + targetPort: 80 +`, s.Namespace()) + err := s.CreateResourceFromString(serviceYAML) + Expect(err).NotTo(HaveOccurred(), "creating service") + + time.Sleep(2 * time.Second) + + By("attempting to create Ingress with two TLS configs using different certificates for same host") + ingressYAML := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-self-conflict + namespace: %s +spec: + ingressClassName: %s + tls: + - hosts: + - %s + secretName: %s + - hosts: + - %s + secretName: %s + rules: + - host: %s + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: test-service-self + port: + number: 80 +`, s.Namespace(), s.Namespace(), host, secretA, host, secretB, host) + err = s.CreateResourceFromString(ingressYAML) + Expect(err).Should(HaveOccurred(), "expecting self-conflict in Ingress") + Expect(err.Error()).To(ContainSubstring("SSL configuration conflicts detected")) + Expect(err.Error()).To(ContainSubstring(host)) + }) + + It("should reject Ingress with conflicting certificate against existing ApisixTls", func() { + host := "ingress-vs-tls.example.com" + secretA := "ingress-cert-a" + secretB := "ingress-cert-b" + + By("creating two different TLS secrets") + createKubeTLSSecret(s, secretA, host, "creating secret A") + createKubeTLSSecret(s, secretB, host, "creating secret B") + + time.Sleep(2 * time.Second) + + By("creating ApisixTls with certificate A") + tlsYAML := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: apisixtls-ingress-test + namespace: %s +spec: + ingressClassName: %s + hosts: + - %s + secret: + name: %s + namespace: %s +`, s.Namespace(), s.Namespace(), host, secretA, s.Namespace()) + err := s.CreateResourceFromString(tlsYAML) + Expect(err).NotTo(HaveOccurred(), "creating ApisixTls") + + time.Sleep(2 * time.Second) + + By("creating a backend service for Ingress") + serviceYAML := fmt.Sprintf(` +apiVersion: v1 +kind: Service +metadata: + name: test-service + namespace: %s +spec: + selector: + app: test + ports: + - port: 80 + targetPort: 80 +`, s.Namespace()) + err = s.CreateResourceFromString(serviceYAML) + Expect(err).NotTo(HaveOccurred(), "creating service") + + time.Sleep(2 * time.Second) + + By("attempting to create Ingress with certificate B for same host") + ingressYAML := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-conflict + namespace: %s +spec: + ingressClassName: %s + tls: + - hosts: + - %s + secretName: %s + rules: + - host: %s + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: test-service + port: + number: 80 +`, s.Namespace(), s.Namespace(), host, secretB, host) + err = s.CreateResourceFromString(ingressYAML) + Expect(err).Should(HaveOccurred(), "expecting conflict when creating Ingress") + Expect(err.Error()).To(ContainSubstring("SSL configuration conflicts detected")) + Expect(err.Error()).To(ContainSubstring(host)) + }) + + It("should allow Ingress with same certificate as existing Gateway", func() { + host := "ingress-gateway-allowed.example.com" + sharedSecret := "ingress-gateway-shared-cert" + + By("creating a shared TLS secret") + createKubeTLSSecret(s, sharedSecret, host, "creating shared secret") + + time.Sleep(2 * time.Second) + + By("creating Gateway with shared certificate") + hostname := host + gatewayYAML := fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-for-ingress + namespace: %s +spec: + gatewayClassName: %s + listeners: + - name: https + protocol: HTTPS + port: 443 + hostname: %s + tls: + mode: Terminate + certificateRefs: + - name: %s + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +`, s.Namespace(), s.Namespace(), hostname, sharedSecret) + err := s.CreateResourceFromString(gatewayYAML) + Expect(err).NotTo(HaveOccurred(), "creating Gateway") + + time.Sleep(2 * time.Second) + + By("creating a backend service for Ingress") + serviceYAML := fmt.Sprintf(` +apiVersion: v1 +kind: Service +metadata: + name: test-service-2 + namespace: %s +spec: + selector: + app: test + ports: + - port: 80 + targetPort: 80 +`, s.Namespace()) + err = s.CreateResourceFromString(serviceYAML) + Expect(err).NotTo(HaveOccurred(), "creating service") + + time.Sleep(2 * time.Second) + + By("creating Ingress with same certificate") + ingressYAML := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-allowed + namespace: %s +spec: + ingressClassName: %s + tls: + - hosts: + - %s + secretName: %s + rules: + - host: %s + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: test-service-2 + port: + number: 80 +`, s.Namespace(), s.Namespace(), host, sharedSecret, host) + err = s.CreateResourceFromString(ingressYAML) + Expect(err).NotTo(HaveOccurred(), "Ingress should be allowed with same certificate") + }) + + It("should reject Ingress when Gateway without hostname uses different certificate", func() { + host := "gateway-ingress-no-host-conflict.example.com" + secretA := "gateway-ingress-no-host-cert-a" + secretB := "gateway-ingress-no-host-cert-b" + + By("creating two different TLS secrets") + createKubeTLSSecret(s, secretA, host, "creating secret A") + createKubeTLSSecret(s, secretB, host, "creating secret B") + + time.Sleep(2 * time.Second) + + By("creating Gateway without explicit hostname using certificate A") + gatewayYAML := fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-ingress-no-host + namespace: %s +spec: + gatewayClassName: %s + listeners: + - name: https + protocol: HTTPS + port: 443 + tls: + mode: Terminate + certificateRefs: + - name: %s + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +`, s.Namespace(), s.Namespace(), secretA) + err := s.CreateResourceFromString(gatewayYAML) + Expect(err).NotTo(HaveOccurred(), "creating Gateway without hostname") + + time.Sleep(2 * time.Second) + + By("creating a backend service for Ingress") + serviceYAML := fmt.Sprintf(` +apiVersion: v1 +kind: Service +metadata: + name: test-service-ingress-no-host + namespace: %s +spec: + selector: + app: test + ports: + - port: 80 + targetPort: 80 +`, s.Namespace()) + err = s.CreateResourceFromString(serviceYAML) + Expect(err).NotTo(HaveOccurred(), "creating service") + + time.Sleep(2 * time.Second) + + By("attempting to create Ingress without explicit host using certificate B") + ingressYAML := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-no-host-conflict + namespace: %s +spec: + ingressClassName: %s + tls: + - secretName: %s + rules: + - http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: test-service-ingress-no-host + port: + number: 80 +`, s.Namespace(), s.Namespace(), secretB) + err = s.CreateResourceFromString(ingressYAML) + Expect(err).Should(HaveOccurred(), "expecting conflict when creating Ingress without hostname") + Expect(err.Error()).To(ContainSubstring("SSL configuration conflicts detected")) + Expect(err.Error()).To(ContainSubstring(host)) + }) + }) + + Context("Default IngressClass conflict detection", func() { + It("should reject Ingress without explicit class when default class uses a different certificate", func() { + host := "default-ingress-conflict.example.com" + secretA := "default-ingress-cert-a" + secretB := "default-ingress-cert-b" + defaultClassName := fmt.Sprintf("%s-default", s.Namespace()) + + By("creating TLS secrets for default ingress test") + createKubeTLSSecret(s, secretA, host, "creating secret A") + createKubeTLSSecret(s, secretB, host, "creating secret B") + + By("creating default IngressClass with APISIX controller") + defaultIngressClassYAML := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + name: %s + annotations: + ingressclass.kubernetes.io/is-default-class: "true" +spec: + controller: %s + parameters: + apiGroup: "apisix.apache.org" + kind: "GatewayProxy" + name: "apisix-proxy-config" + namespace: %s + scope: Namespace +`, defaultClassName, s.GetControllerName(), s.Namespace()) + err := s.CreateResourceFromStringWithNamespace(defaultIngressClassYAML, "") + Expect(err).NotTo(HaveOccurred(), "creating default IngressClass") + + time.Sleep(2 * time.Second) + + By("creating backend service for default ingress test") + serviceYAML := fmt.Sprintf(` +apiVersion: v1 +kind: Service +metadata: + name: test-service-default + namespace: %s +spec: + selector: + app: test + ports: + - port: 80 + targetPort: 80 +`, s.Namespace()) + err = s.CreateResourceFromString(serviceYAML) + Expect(err).NotTo(HaveOccurred(), "creating service") + + time.Sleep(2 * time.Second) + + By("creating baseline Ingress with certificate A") + ingressAYAML := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-default-a + namespace: %s +spec: + tls: + - hosts: + - %s + secretName: %s + rules: + - host: %s + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: test-service-default + port: + number: 80 +`, s.Namespace(), host, secretA, host) + err = s.CreateResourceFromString(ingressAYAML) + Expect(err).NotTo(HaveOccurred(), "creating baseline Ingress") + + time.Sleep(2 * time.Second) + + By("attempting to create second Ingress with conflicting certificate via default class") + ingressBYAML := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-default-b + namespace: %s +spec: + tls: + - hosts: + - %s + secretName: %s + rules: + - host: %s + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: test-service-default + port: + number: 80 +`, s.Namespace(), host, secretB, host) + err = s.CreateResourceFromString(ingressBYAML) + Expect(err).Should(HaveOccurred(), "expecting conflict when creating second Ingress") + Expect(err.Error()).To(ContainSubstring("SSL configuration conflicts detected")) + Expect(err.Error()).To(ContainSubstring(host)) + }) + + It("should reject ApisixTls without explicit class when default class uses a different certificate", func() { + host := "default-tls-conflict.example.com" + secretA := "default-tls-cert-a" + secretB := "default-tls-cert-b" + defaultClassName := fmt.Sprintf("%s-default-tls", s.Namespace()) + + By("creating TLS secrets for default ApisixTls test") + createKubeTLSSecret(s, secretA, host, "creating secret A") + createKubeTLSSecret(s, secretB, host, "creating secret B") + + By("creating default IngressClass required for ApisixTls admission") + defaultIngressClassYAML := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + name: %s + annotations: + ingressclass.kubernetes.io/is-default-class: "true" +spec: + controller: %s + parameters: + apiGroup: "apisix.apache.org" + kind: "GatewayProxy" + name: "apisix-proxy-config" + namespace: %s + scope: Namespace +`, defaultClassName, s.GetControllerName(), s.Namespace()) + err := s.CreateResourceFromStringWithNamespace(defaultIngressClassYAML, "") + Expect(err).NotTo(HaveOccurred(), "creating default IngressClass") + + time.Sleep(2 * time.Second) + + By("creating baseline ApisixTls without explicit ingress class") + tlsAYAML := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: tls-default-a + namespace: %s +spec: + hosts: + - %s + secret: + name: %s + namespace: %s +`, s.Namespace(), host, secretA, s.Namespace()) + err = s.CreateResourceFromString(tlsAYAML) + Expect(err).NotTo(HaveOccurred(), "creating baseline ApisixTls") + + time.Sleep(2 * time.Second) + + By("attempting to create ApisixTls with conflicting certificate without class override") + tlsBYAML := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: tls-default-b + namespace: %s +spec: + hosts: + - %s + secret: + name: %s + namespace: %s +`, s.Namespace(), host, secretB, s.Namespace()) + err = s.CreateResourceFromString(tlsBYAML) + Expect(err).Should(HaveOccurred(), "expecting conflict when creating second ApisixTls") + Expect(err.Error()).To(ContainSubstring("SSL configuration conflicts detected")) + Expect(err.Error()).To(ContainSubstring(host)) + }) + }) + + Context("Update scenario conflict detection", func() { + It("should reject Ingress update that switches to a conflicting certificate", func() { + host := "ingress-update-conflict.example.com" + secretA := "ingress-update-cert-a" + secretB := "ingress-update-cert-b" + + By("creating TLS secrets for ingress update test") + createKubeTLSSecret(s, secretA, host, "creating secret A") + createKubeTLSSecret(s, secretB, host, "creating secret B") + + By("creating ApisixTls with certificate A to establish existing mapping") + tlsYAML := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: tls-update-baseline + namespace: %s +spec: + ingressClassName: %s + hosts: + - %s + secret: + name: %s + namespace: %s +`, s.Namespace(), s.Namespace(), host, secretA, s.Namespace()) + err := s.CreateResourceFromString(tlsYAML) + Expect(err).NotTo(HaveOccurred(), "creating baseline ApisixTls for ingress update") + + time.Sleep(2 * time.Second) + + By("creating backend service for ingress update test") + serviceYAML := fmt.Sprintf(` +apiVersion: v1 +kind: Service +metadata: + name: test-service-update + namespace: %s +spec: + selector: + app: test + ports: + - port: 80 + targetPort: 80 +`, s.Namespace()) + err = s.CreateResourceFromString(serviceYAML) + Expect(err).NotTo(HaveOccurred(), "creating service") + + time.Sleep(2 * time.Second) + + By("creating initial Ingress with matching certificate") + ingressBaseYAML := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-update + namespace: %s +spec: + ingressClassName: %s + tls: + - hosts: + - %s + secretName: %s + rules: + - host: %s + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: test-service-update + port: + number: 80 +`, s.Namespace(), s.Namespace(), host, secretA, host) + err = s.CreateResourceFromString(ingressBaseYAML) + Expect(err).NotTo(HaveOccurred(), "creating initial Ingress") + + time.Sleep(2 * time.Second) + + By("attempting to update Ingress to use conflicting certificate B") + ingressUpdatedYAML := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-update + namespace: %s +spec: + ingressClassName: %s + tls: + - hosts: + - %s + secretName: %s + rules: + - host: %s + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: test-service-update + port: + number: 80 +`, s.Namespace(), s.Namespace(), host, secretB, host) + err = s.CreateResourceFromString(ingressUpdatedYAML) + Expect(err).Should(HaveOccurred(), "expecting conflict when updating Ingress certificate") + Expect(err.Error()).To(ContainSubstring("SSL configuration conflicts detected")) + Expect(err.Error()).To(ContainSubstring(host)) + }) + + It("should reject Gateway update that switches to a conflicting certificate", func() { + host := "gateway-update-conflict.example.com" + secretA := "gateway-update-cert-a" + secretB := "gateway-update-cert-b" + + By("creating TLS secrets for gateway update test") + createKubeTLSSecret(s, secretA, host, "creating secret A") + createKubeTLSSecret(s, secretB, host, "creating secret B") + + By("creating ApisixTls with certificate A to establish host ownership") + tlsYAML := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: tls-gateway-update + namespace: %s +spec: + ingressClassName: %s + hosts: + - %s + secret: + name: %s + namespace: %s +`, s.Namespace(), s.Namespace(), host, secretA, s.Namespace()) + err := s.CreateResourceFromString(tlsYAML) + Expect(err).NotTo(HaveOccurred(), "creating baseline ApisixTls for gateway update") + + time.Sleep(2 * time.Second) + + By("creating initial Gateway using certificate A") + gatewayBaseYAML := fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-update + namespace: %s +spec: + gatewayClassName: %s + listeners: + - name: https + protocol: HTTPS + port: 443 + hostname: %s + tls: + mode: Terminate + certificateRefs: + - name: %s + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +`, s.Namespace(), s.Namespace(), host, secretA) + err = s.CreateResourceFromString(gatewayBaseYAML) + Expect(err).NotTo(HaveOccurred(), "creating initial Gateway") + + time.Sleep(2 * time.Second) + + By("attempting to update Gateway to use conflicting certificate B") + gatewayUpdatedYAML := fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-update + namespace: %s +spec: + gatewayClassName: %s + listeners: + - name: https + protocol: HTTPS + port: 443 + hostname: %s + tls: + mode: Terminate + certificateRefs: + - name: %s + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +`, s.Namespace(), s.Namespace(), host, secretB) + err = s.CreateResourceFromString(gatewayUpdatedYAML) + Expect(err).Should(HaveOccurred(), "expecting conflict when updating Gateway certificate") + Expect(err.Error()).To(ContainSubstring("SSL configuration conflicts detected")) + Expect(err.Error()).To(ContainSubstring(host)) + }) + }) + + Context("Mixed resource conflict detection", func() { + It("should handle conflicts among Gateway, Ingress, and ApisixTls", func() { + host := "mixed.example.com" + secretA := "mixed-cert-a" + secretB := "mixed-cert-b" + secretC := "mixed-cert-c" + + By("creating three different TLS secrets") + createKubeTLSSecret(s, secretA, host, "creating secret A") + createKubeTLSSecret(s, secretB, host, "creating secret B") + createKubeTLSSecret(s, secretC, host, "creating secret C") + + time.Sleep(2 * time.Second) + + By("creating Gateway with certificate A") + hostname := host + gatewayYAML := fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-mixed + namespace: %s +spec: + gatewayClassName: %s + listeners: + - name: https + protocol: HTTPS + port: 443 + hostname: %s + tls: + mode: Terminate + certificateRefs: + - name: %s + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +`, s.Namespace(), s.Namespace(), hostname, secretA) + err := s.CreateResourceFromString(gatewayYAML) + Expect(err).NotTo(HaveOccurred(), "creating Gateway with cert A") + + time.Sleep(2 * time.Second) + + By("attempting to create ApisixTls with certificate B") + tlsYAML := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: apisixtls-mixed + namespace: %s +spec: + ingressClassName: %s + hosts: + - %s + secret: + name: %s + namespace: %s +`, s.Namespace(), s.Namespace(), host, secretB, s.Namespace()) + err = s.CreateResourceFromString(tlsYAML) + Expect(err).Should(HaveOccurred(), "expecting conflict when creating ApisixTls with different cert") + Expect(err.Error()).To(ContainSubstring("SSL configuration conflicts detected")) + + By("creating a backend service") + serviceYAML := fmt.Sprintf(` +apiVersion: v1 +kind: Service +metadata: + name: test-service-3 + namespace: %s +spec: + selector: + app: test + ports: + - port: 80 + targetPort: 80 +`, s.Namespace()) + err = s.CreateResourceFromString(serviceYAML) + Expect(err).NotTo(HaveOccurred(), "creating service") + + time.Sleep(2 * time.Second) + + By("attempting to create Ingress with certificate C") + ingressYAML := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-mixed + namespace: %s +spec: + ingressClassName: %s + tls: + - hosts: + - %s + secretName: %s + rules: + - host: %s + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: test-service-3 + port: + number: 80 +`, s.Namespace(), s.Namespace(), host, secretC, host) + err = s.CreateResourceFromString(ingressYAML) + Expect(err).Should(HaveOccurred(), "expecting conflict when creating Ingress with different cert") + Expect(err.Error()).To(ContainSubstring("SSL configuration conflicts detected")) + }) + }) +}) + +func createApisixTLSSecret(s *scaffold.Scaffold, secretName, host, failureMessage string) { + cert, key := s.GenerateCert(GinkgoT(), []string{host}) + err := s.NewSecret(secretName, cert.String(), key.String()) + Expect(err).NotTo(HaveOccurred(), failureMessage) +} + +func createKubeTLSSecret(s *scaffold.Scaffold, secretName, host, failureMessage string) { + cert, key := s.GenerateCert(GinkgoT(), []string{host}) + err := s.NewKubeTlsSecret(secretName, cert.String(), key.String()) + Expect(err).NotTo(HaveOccurred(), failureMessage) +}