This is an automated email from the ASF dual-hosted git repository.
ronething pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git
The following commit(s) were added to refs/heads/master by this push:
new 351d20a5 feat: add certificate conflict detection to admission
webhooks (#2603)
351d20a5 is described below
commit 351d20a51af496012b822bae225839cac6832750
Author: Ashing Zheng <[email protected]>
AuthorDate: Thu Oct 16 21:58:11 2025 +0800
feat: add certificate conflict detection to admission webhooks (#2603)
Signed-off-by: Ashing Zheng <[email protected]>
---
internal/adc/translator/apisixtls.go | 5 +-
internal/adc/translator/apisixupstream.go | 3 +-
internal/adc/translator/gateway.go | 70 +-
internal/adc/translator/ingress.go | 5 +-
internal/controller/indexer/indexer.go | 29 +
internal/controller/indexer/ssl_host.go | 143 +++
internal/ssl/util.go | 142 +++
internal/webhook/v1/apisixtls_webhook.go | 13 +
internal/webhook/v1/gateway_webhook.go | 13 +
internal/webhook/v1/ingress_webhook.go | 14 +-
internal/webhook/v1/ssl/conflict_detector.go | 513 +++++++++
internal/webhook/v1/ssl/conflict_detector_test.go | 418 +++++++
test/e2e/webhook/ssl_conflict.go | 1215 +++++++++++++++++++++
13 files changed, 2510 insertions(+), 73 deletions(-)
diff --git a/internal/adc/translator/apisixtls.go
b/internal/adc/translator/apisixtls.go
index bd67893e..ea428711 100644
--- a/internal/adc/translator/apisixtls.go
+++ b/internal/adc/translator/apisixtls.go
@@ -27,6 +27,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"
)
@@ -44,7 +45,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
}
@@ -81,7 +82,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 86a39e62..33e626fe 100644
--- a/internal/adc/translator/apisixupstream.go
+++ b/internal/adc/translator/apisixupstream.go
@@ -29,6 +29,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"
)
@@ -187,7 +188,7 @@ func translateApisixUpstreamClientTLS(tctx
*provider.TranslateContext, config *a
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 2ee2454c..53c67144 100644
--- a/internal/adc/translator/gateway.go
+++ b/internal/adc/translator/gateway.go
@@ -18,13 +18,10 @@
package translator
import (
- "crypto/x509"
"encoding/json"
- "encoding/pem"
"fmt"
"github.com/pkg/errors"
- corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
@@ -33,6 +30,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"
)
@@ -97,7 +95,7 @@ func (t *Translator) translateSecret(tctx
*provider.TranslateContext, listener g
t.Log.Error(errors.New("secret data is
nil"), "failed to get secret data", "secret", secretNN)
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 {
t.Log.Error(err, "extract key pair",
"secret", secretNN)
return nil, err
@@ -110,7 +108,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
}
@@ -137,68 +135,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 1d2a4186..894224b2 100644
--- a/internal/adc/translator/ingress.go
+++ b/internal/adc/translator/ingress.go
@@ -32,19 +32,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(namespace, name string, tlsIndex int,
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 8982630e..b234a1c2 100644
--- a/internal/controller/indexer/indexer.go
+++ b/internal/controller/indexer/indexer.go
@@ -46,6 +46,7 @@ const (
IngressClassParametersRef = "ingressClassParametersRef"
ConsumerGatewayRef = "consumerGatewayRef"
PolicyTargetRefs = "targetRefs"
+ TLSHostIndexRef = "tlsHostRefs"
GatewayClassIndexRef = "gatewayClassRef"
ApisixUpstreamRef = "apisixUpstreamRef"
PluginConfigIndexRef = "pluginConfigRefs"
@@ -99,6 +100,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
}
@@ -404,6 +415,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
}
@@ -849,6 +869,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 16bcf88f..a033fc48 100644
--- a/internal/webhook/v1/apisixtls_webhook.go
+++ b/internal/webhook/v1/apisixtls_webhook.go
@@ -30,6 +30,7 @@ import (
apisixv2 "github.com/apache/apisix-ingress-controller/api/v2"
"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"
)
var apisixTlsLog = logf.Log.WithName("apisixtls-resource")
@@ -67,6 +68,12 @@ func (v *ApisixTlsCustomValidator) ValidateCreate(ctx
context.Context, obj runti
return nil, nil
}
+ 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
}
@@ -80,6 +87,12 @@ func (v *ApisixTlsCustomValidator) ValidateUpdate(ctx
context.Context, oldObj, n
return nil, nil
}
+ 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 bb21b236..baf67784 100644
--- a/internal/webhook/v1/gateway_webhook.go
+++ b/internal/webhook/v1/gateway_webhook.go
@@ -33,6 +33,7 @@ import (
v1alpha1 "github.com/apache/apisix-ingress-controller/api/v1alpha1"
internaltypes
"github.com/apache/apisix-ingress-controller/internal/types"
"github.com/apache/apisix-ingress-controller/internal/webhook/v1/reference"
+ sslvalidator
"github.com/apache/apisix-ingress-controller/internal/webhook/v1/ssl"
)
// nolint:unused
@@ -86,6 +87,12 @@ func (v *GatewayCustomValidator) ValidateCreate(ctx
context.Context, obj runtime
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))
+ }
+
warnings := v.warnIfMissingGatewayProxyForGateway(ctx, gateway)
warnings = append(warnings, v.collectReferenceWarnings(ctx, gateway)...)
@@ -109,6 +116,12 @@ func (v *GatewayCustomValidator) ValidateUpdate(ctx
context.Context, oldObj, new
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))
+ }
+
warnings := v.warnIfMissingGatewayProxyForGateway(ctx, gateway)
warnings = append(warnings, v.collectReferenceWarnings(ctx, gateway)...)
diff --git a/internal/webhook/v1/ingress_webhook.go
b/internal/webhook/v1/ingress_webhook.go
index 10e18ab4..03ee8f5b 100644
--- a/internal/webhook/v1/ingress_webhook.go
+++ b/internal/webhook/v1/ingress_webhook.go
@@ -31,6 +31,7 @@ import (
"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"
)
var ingresslog = logf.Log.WithName("ingress-resource")
@@ -142,6 +143,12 @@ func (v *IngressCustomValidator) ValidateCreate(ctx
context.Context, obj runtime
return nil, nil
}
+ 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)
warnings = append(warnings, v.collectReferenceWarnings(ctx, ingress)...)
@@ -160,10 +167,15 @@ func (v *IngressCustomValidator) ValidateUpdate(ctx
context.Context, oldObj, new
return nil, nil
}
+ 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)
warnings = append(warnings, v.collectReferenceWarnings(ctx, ingress)...)
-
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/webhook/ssl_conflict.go b/test/e2e/webhook/ssl_conflict.go
new file mode 100644
index 00000000..8c0ff4bb
--- /dev/null
+++ b/test/e2e/webhook/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)
+}