blob: c39cd274de12a5a223a4a938109ab397112d0f88 [file] [log] [blame]
// 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()}
}