diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 5780e59..65ab192 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -5,6 +5,12 @@ metadata: creationTimestamp: null name: manager-role rules: +- apiGroups: + - "" + resources: + - endpoints + verbs: + - get - apiGroups: - "" resources: diff --git a/deploy/charts/route-to-contour-httpproxy/templates/manager-rbac.yaml b/deploy/charts/route-to-contour-httpproxy/templates/manager-rbac.yaml index 1c7dc74..82a994f 100644 --- a/deploy/charts/route-to-contour-httpproxy/templates/manager-rbac.yaml +++ b/deploy/charts/route-to-contour-httpproxy/templates/manager-rbac.yaml @@ -25,6 +25,12 @@ rules: - get - list - watch +- apiGroups: + - "" + resources: + - endpoints + verbs: + - get - apiGroups: - projectcontour.io resources: @@ -80,4 +86,4 @@ roleRef: subjects: - kind: ServiceAccount name: '{{ include "route-to-contour-httpproxy.fullname" . }}-controller-manager' - namespace: '{{ .Release.Namespace }}' \ No newline at end of file + namespace: '{{ .Release.Namespace }}' diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go index efc84a2..07cc717 100644 --- a/internal/controller/controller_test.go +++ b/internal/controller/controller_test.go @@ -32,6 +32,7 @@ const ( RouterName = "default" RouteTimeout = "120s" + FirstEndpointsIP = "10.10.10.10" FirstServiceName = "foo" FirstRouteName = "foo" SecondRouteName = "bar" @@ -43,11 +44,61 @@ const ( RateLimitRequests = 100 RouteIPWhiteList = "1.1.1.1 8.8.8.8" + + WildcardCert = ` +-----BEGIN CERTIFICATE----- +MIIDYzCCAksCFCg7O6rz+l2xyXChgE4ae0e7R64MMA0GCSqGSIb3DQEBCwUAMG4x +CzAJBgNVBAYTAklSMQ8wDQYDVQQIDAZUZWhyYW4xDzANBgNVBAcMBlRlaHJhbjEO +MAwGA1UECgwFU25hcHAxEzARBgNVBAsMClNuYXBwQ2xvdWQxGDAWBgNVBAMMDyou +c25hcHBjbG91ZC5pbzAeFw0yMzExMjEyMDE1MzZaFw0yNDExMjAyMDE1MzZaMG4x +CzAJBgNVBAYTAklSMQ8wDQYDVQQIDAZUZWhyYW4xDzANBgNVBAcMBlRlaHJhbjEO +MAwGA1UECgwFU25hcHAxEzARBgNVBAsMClNuYXBwQ2xvdWQxGDAWBgNVBAMMDyou +c25hcHBjbG91ZC5pbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALl0 +aldira6ue+gD1uJxo2sViJWzmwITtERhX0HMXJRz1/zFZ9dWavbjgPYplUiS1v1g +TymcjDqF2ctChZZAjOs2iGaXixA3lKCbKPzXVAPeqyhTIw0N/rwKbmBGVRhIIwI1 +pf1TMyYJiBYuCDN5pf3KZJ1kJ7SqBJO9Qr8wYtZZ+cccvZtpMK+FAsrNef0FFq7P +0ZXpG0/BB5Oyj3OW2jyy1OKx+nfuEKugnQ50SOi2jD1XeSjOK1YysrY2Ucy9QHK7 +9p5pQNcy1VyRdqXAlDp2Y3MaoswEIxF6mBrBo6os5JJvXHvGFL5XYOAFTOC+lxW9 +SyqpP9DNDnGiQDv5z9ECAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAgjsJTMRGauy7 +1BudiattL9C31V5/6tMWf8qATJF7cpdXBS6c5xoMgRg1Uv+E8ZKqH5cjqTbG/Rns +KHUpJngavKMw61yFiyDr6xce2svEfn4+Mr42UpBviVQnfE0cBPd17JHiVMBK2nOG +i7dFAZ0q0nfU3gh4PCGLzdW49tSz3Bt9SDT+9H3t1FnHOdaCcO6SKufnz4IqEoVG +D3ByZu9s4D/4Yoh4NeP/sHEhP3KnTIQQ+4dh1xWs2/5Hd8l5xBid5esWdOrwWb7s +XZyVH1FuumB9pepOLY4TYAskLS2/N47DDFjHRucVxHXkhjASh3RXe7+/YSvCTEwQ +RMsUplRDZg== +-----END CERTIFICATE-----` + WildcardKey = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC5dGpXYq2urnvo +A9bicaNrFYiVs5sCE7REYV9BzFyUc9f8xWfXVmr244D2KZVIktb9YE8pnIw6hdnL +QoWWQIzrNohml4sQN5Sgmyj811QD3qsoUyMNDf68Cm5gRlUYSCMCNaX9UzMmCYgW +LggzeaX9ymSdZCe0qgSTvUK/MGLWWfnHHL2baTCvhQLKzXn9BRauz9GV6RtPwQeT +so9zlto8stTisfp37hCroJ0OdEjotow9V3kozitWMrK2NlHMvUByu/aeaUDXMtVc +kXalwJQ6dmNzGqLMBCMRepgawaOqLOSSb1x7xhS+V2DgBUzgvpcVvUsqqT/QzQ5x +okA7+c/RAgMBAAECggEAIaRJICYB7LSxPHLp2bUUmHnVB5cHsPZDFr51Kbn5N2LW +VP+4aRs/lx7JB56eeoZMorUEVz+TPpCGZDVih1GZXpfLYZTvAJecig/rfQZQss0D +TnLaYmVeBt17jVJk4F1BoIZ74HrlxeonuiJKkY/pOSMsYlLHUyIeZ3CHOah83XYw +xstfOpJbSblz7ph4AB27wA6VgSz5xpu7hhUym/cSaDpNO+MpuLc/hvV/Qg4cxfBT +WI64F2vKeeB0adxlN0RC8oX+q1jWHUCGicgwm7Vns3RWgljFQk52QEY4UzwvQD90 +z8/RRuWzXoA5lj9Zk6m2Fzm1xatWxSPDAf3/j+lNcwKBgQDm0BtLRGkn9rgdbYiF +lLKuk0F5pfIeEQrETb4jHQURZ9rRQ3+vFEjdKJQkDKLPvdhtUSNAEkyYuzu+Euo8 +JpAJn9VOQNgCCe/AynMDKioDzVxS5ZNV8Zf5icsG/697PikmP+xTX+upQ/s+5Ey7 +s0WwvX07XNjEwaFTguv4J2aUtwKBgQDNsTOvACzx7VCwFvF/+Xk+vFa+LuWJllIL +HjtL2fvnZwTWNu8TWeQuZKzhlT/jvDdRG9sllh2d5V/3w6I89HN5G7pvRebqtkAl +Q2jO7/cW8s1Mf3YPeHZV5QBhLF9xDjttdWSpKXv9dZwYip45iU5a5UxjwSv/v6Wx +OegWUY+HtwKBgQC/mbN+mKx+O0V9UEbLNLPbTWxF0maZZOY+LJcQyO9DEqZHnrOo +n7sYs629+ytQLjUyEe+kKUyiYJLoZwVAp3ZcNu04B4YIszzuGmC9GMxF2byxJ9hV +uLbCtArwpWGDegdotBm24GJdYYx4GcZE7j2EyNfjZmCffGkyTPUbS4HRIwKBgAPp +IJBtMm2PE3+lkAXc2l9E+Wk4Pwj0oK6xbnMsu8tUfBUOilEV3m67X0YSrlpIE80o ++GuohPuhhseRIp6CD0f4LP08mP1RZbrPo0h763i2OQ0BR19X7PgJGI7AZzghCyQz +nSxSK5dQCx20VPnHEIRN47vpykpcfGv4K99wwYfVAoGBAMt7EHyH9OSUeDxMXYCU +zN9qMOvGvppU7//jgcBe23vFf9s2nv1wjkJCH68Tx/TsMjanCeipcT/weYcZM175 +x1FHKiB/ZOqlE9MDamNJlgX+hNJKzJNe9jLkMl1PvUGIwZyV2BrX4VmADLz7jX1Q +VrNBygXnThcxgGU9gP1srPrq +-----END PRIVATE KEY-----` ) var ( - ServiceWeight int32 = 100 - FirstServicePorts = []v12.ServicePort{{Name: "https", Port: 443}} + ServiceWeight int32 = 100 ) var _ = Describe("Testing Route to HTTPProxy Controller", func() { @@ -65,14 +116,37 @@ var _ = Describe("Testing Route to HTTPProxy Controller", func() { Name: FirstServiceName, }, Spec: v12.ServiceSpec{ - Ports: FirstServicePorts, - Type: v12.ServiceTypeClusterIP, + Ports: []v12.ServicePort{ + {Name: "https", Port: 443, TargetPort: intstr.IntOrString{Type: intstr.Int, IntVal: 8443}}, + {Name: "http", Port: 80, TargetPort: intstr.IntOrString{Type: intstr.Int, IntVal: 8080}}, + }, + Type: v12.ServiceTypeClusterIP, Selector: map[string]string{ "app": "test", }, }, } Expect(k8sClient.Create(context.Background(), &objService)).To(Succeed()) + + objEndpoints := v12.Endpoints{ + ObjectMeta: v1.ObjectMeta{ + Namespace: DefaultNamespace, + Name: FirstServiceName, + }, + Subsets: []v12.EndpointSubset{ + { + Addresses: []v12.EndpointAddress{ + {IP: FirstEndpointsIP}, + }, + Ports: []v12.EndpointPort{ + {Name: "https", Port: 8443, Protocol: v12.ProtocolTCP}, + {Name: "http", Port: 8080, Protocol: v12.ProtocolTCP}, + }, + }, + }, + } + + Expect(k8sClient.Create(context.Background(), &objEndpoints)).To(Succeed()) }) getSampleRoute := func() *routev1.Route { @@ -87,7 +161,7 @@ var _ = Describe("Testing Route to HTTPProxy Controller", func() { Spec: routev1.RouteSpec{ Host: FirstRouteFQDN, Port: &routev1.RoutePort{ - TargetPort: intstr.IntOrString{IntVal: 443}, + TargetPort: intstr.IntOrString{IntVal: 8443}, }, To: routev1.RouteTargetReference{ Name: FirstServiceName, @@ -165,11 +239,89 @@ var _ = Describe("Testing Route to HTTPProxy Controller", func() { cleanUpRoute(objRoute) }) - It("should create HTTPProxy object when everything is alright", func() { + It("should create HTTPProxy object when everything is alright (valid targetPort as integer)", func() { + objRoute := getSampleRoute() + objRoute.Annotations = map[string]string{ + consts.AnnotTimeout: RouteTimeout, + } + objRoute.Spec.Port.TargetPort = intstr.IntOrString{IntVal: 8443} + Expect(k8sClient.Create(context.Background(), objRoute)).To(Succeed()) + + admitRoute(objRoute) + + rObj := routev1.Route{} + Expect(k8sClient.Get(context.Background(), types.NamespacedName{Namespace: DefaultNamespace, Name: FirstRouteName}, &rObj)).To(Succeed()) + + Eventually(func(g Gomega) { + httpProxyList := contourv1.HTTPProxyList{} + g.Expect(k8sClient.List(context.Background(), &httpProxyList, client.InNamespace(DefaultNamespace))).To(Succeed()) + g.Expect(len(httpProxyList.Items)).To(Equal(1)) + g.Expect(httpProxyList.Items[0].Spec.VirtualHost.Fqdn).To(Equal(FirstRouteFQDN)) + g.Expect(httpProxyList.Items[0].Spec.Routes[0].TimeoutPolicy.Response).To(Equal(RouteTimeout)) + g.Expect(len(httpProxyList.Items[0].Spec.Routes[0].Services)).To(Equal(1)) + g.Expect(httpProxyList.Items[0].Spec.Routes[0].Services[0].Port).To(Equal(443)) + }).Should(Succeed()) + + cleanUpRoute(objRoute) + }) + + It("should create HTTPProxy object when everything is alright (valid targetPort as string)", func() { + objRoute := getSampleRoute() + objRoute.Annotations = map[string]string{ + consts.AnnotTimeout: RouteTimeout, + } + objRoute.Spec.Port.TargetPort = intstr.IntOrString{Type: intstr.String, StrVal: "https"} + Expect(k8sClient.Create(context.Background(), objRoute)).To(Succeed()) + + admitRoute(objRoute) + + rObj := routev1.Route{} + Expect(k8sClient.Get(context.Background(), types.NamespacedName{Namespace: DefaultNamespace, Name: FirstRouteName}, &rObj)).To(Succeed()) + + Eventually(func(g Gomega) { + httpProxyList := contourv1.HTTPProxyList{} + g.Expect(k8sClient.List(context.Background(), &httpProxyList, client.InNamespace(DefaultNamespace))).To(Succeed()) + g.Expect(len(httpProxyList.Items)).To(Equal(1)) + g.Expect(httpProxyList.Items[0].Spec.VirtualHost.Fqdn).To(Equal(FirstRouteFQDN)) + g.Expect(httpProxyList.Items[0].Spec.Routes[0].TimeoutPolicy.Response).To(Equal(RouteTimeout)) + g.Expect(len(httpProxyList.Items[0].Spec.Routes[0].Services)).To(Equal(1)) + g.Expect(httpProxyList.Items[0].Spec.Routes[0].Services[0].Port).To(Equal(443)) + }).Should(Succeed()) + + cleanUpRoute(objRoute) + }) + + It("should create HTTPProxy object when everything is alright (invalid targetPort as integer)", func() { + objRoute := getSampleRoute() + objRoute.Annotations = map[string]string{ + consts.AnnotTimeout: RouteTimeout, + } + objRoute.Spec.Port.TargetPort = intstr.IntOrString{IntVal: 443} + Expect(k8sClient.Create(context.Background(), objRoute)).To(Succeed()) + + admitRoute(objRoute) + + rObj := routev1.Route{} + Expect(k8sClient.Get(context.Background(), types.NamespacedName{Namespace: DefaultNamespace, Name: FirstRouteName}, &rObj)).To(Succeed()) + + Eventually(func(g Gomega) { + httpProxyList := contourv1.HTTPProxyList{} + g.Expect(k8sClient.List(context.Background(), &httpProxyList, client.InNamespace(DefaultNamespace))).To(Succeed()) + g.Expect(len(httpProxyList.Items)).To(Equal(1)) + g.Expect(httpProxyList.Items[0].Spec.VirtualHost.Fqdn).To(Equal(FirstRouteFQDN)) + g.Expect(httpProxyList.Items[0].Spec.Routes[0].TimeoutPolicy.Response).To(Equal(RouteTimeout)) + g.Expect(len(httpProxyList.Items[0].Spec.Routes)).To(Equal(2)) + }).Should(Succeed()) + + cleanUpRoute(objRoute) + }) + + It("should create HTTPProxy object when everything is alright (invalid targetPort as string)", func() { objRoute := getSampleRoute() objRoute.Annotations = map[string]string{ consts.AnnotTimeout: RouteTimeout, } + objRoute.Spec.Port.TargetPort = intstr.IntOrString{Type: intstr.String, StrVal: "notValid"} Expect(k8sClient.Create(context.Background(), objRoute)).To(Succeed()) admitRoute(objRoute) @@ -183,6 +335,7 @@ var _ = Describe("Testing Route to HTTPProxy Controller", func() { g.Expect(len(httpProxyList.Items)).To(Equal(1)) g.Expect(httpProxyList.Items[0].Spec.VirtualHost.Fqdn).To(Equal(FirstRouteFQDN)) g.Expect(httpProxyList.Items[0].Spec.Routes[0].TimeoutPolicy.Response).To(Equal(RouteTimeout)) + g.Expect(len(httpProxyList.Items[0].Spec.Routes)).To(Equal(2)) }).Should(Succeed()) cleanUpRoute(objRoute) @@ -465,6 +618,28 @@ var _ = Describe("Testing Route to HTTPProxy Controller", func() { cleanUpRoute(route) }) + It("Should set http versions to [http/1.1] for non-inter-dc routes that use a custom wildcard certificate", func() { + route := getSampleRoute() + route.Spec.TLS = &routev1.TLSConfig{ + Termination: routev1.TLSTerminationEdge, + } + route.Spec.TLS.Key = WildcardKey + route.Spec.TLS.Certificate = WildcardCert + Expect(k8sClient.Create(context.Background(), route)).To(Succeed()) + + admitRoute(route) + + Eventually(func(g Gomega) { + httpProxyList := contourv1.HTTPProxyList{} + g.Expect(k8sClient.List(context.Background(), &httpProxyList, client.InNamespace(DefaultNamespace))).To(Succeed()) + g.Expect(len(httpProxyList.Items)).To(Equal(1)) + g.Expect(len(httpProxyList.Items[0].Spec.HttpVersions)).To(Equal(1)) + g.Expect(httpProxyList.Items[0].Spec.HttpVersions[0]).To(Equal(contourv1.HttpVersion("http/1.1"))) + }).Should(Succeed()) + + cleanUpRoute(route) + }) + It("Should set http versions to [h2, http/1.1] for inter-dc routes that use the default certificate", func() { route := getSampleRoute() route.Spec.TLS = &routev1.TLSConfig{ @@ -486,5 +661,29 @@ var _ = Describe("Testing Route to HTTPProxy Controller", func() { cleanUpRoute(route) }) + + It("Should set http versions to [h2, http/1.1] for inter-dc routes that use a custom wildcard certificate", func() { + route := getSampleRoute() + route.Spec.TLS = &routev1.TLSConfig{ + Termination: routev1.TLSTerminationEdge, + } + route.Labels[consts.RouteShardLabel] = consts.IngressClassInterDc + route.Spec.TLS.Key = WildcardKey + route.Spec.TLS.Certificate = WildcardCert + Expect(k8sClient.Create(context.Background(), route)).To(Succeed()) + + admitRoute(route) + + Eventually(func(g Gomega) { + httpProxyList := contourv1.HTTPProxyList{} + g.Expect(k8sClient.List(context.Background(), &httpProxyList, client.InNamespace(DefaultNamespace))).To(Succeed()) + g.Expect(len(httpProxyList.Items)).To(Equal(1)) + g.Expect(len(httpProxyList.Items[0].Spec.HttpVersions)).To(Equal(2)) + g.Expect(httpProxyList.Items[0].Spec.HttpVersions[0]).To(Equal(contourv1.HttpVersion("h2"))) + g.Expect(httpProxyList.Items[0].Spec.HttpVersions[1]).To(Equal(contourv1.HttpVersion("http/1.1"))) + }).Should(Succeed()) + + cleanUpRoute(route) + }) }) }) diff --git a/internal/controller/route/handler.go b/internal/controller/route/handler.go index a9de418..073af3f 100644 --- a/internal/controller/route/handler.go +++ b/internal/controller/route/handler.go @@ -71,6 +71,7 @@ func NewReconciler(mgr manager.Manager, cfg *config.Config) *Reconciler { // +kubebuilder:rbac:groups=projectcontour.io,resources=httpproxies,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch +// +kubebuilder:rbac:groups=core,resources=endpoints,verbs=get func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { r.logger = log.FromContext(ctx) @@ -313,10 +314,20 @@ func (r *Reconciler) assembleHttpproxy(ctx context.Context, owner *routev1.Route return nil, fmt.Errorf("failed to find tls secret: %w", err) } secretName = secret.Name + // check if certificate is wildcard to decide the http version(s) + wildcardCert, err := utils.IsWildcardCertificate(owner.Spec.TLS.Certificate) + if err != nil { + return nil, fmt.Errorf("failed to check if certificate is wildcard: %w", err) + } + // Disable h2 for routes that: + // - utilize custom wildcard certificates + // - *and* are not in the inter-dc shard + if wildcardCert && httpproxy.Spec.IngressClassName != consts.IngressClassInterDc { + httpproxy.Spec.HttpVersions = []contourv1.HttpVersion{"http/1.1"} + } } else { // default tls certificate secretName = consts.GlobalTLSSecretName - // Disable h2 for routes that: // - utilize the default certificate // - *and* are not in the inter-dc shard @@ -367,6 +378,7 @@ func (r *Reconciler) assembleHttpproxy(ctx context.Context, owner *routev1.Route ports, err := r.getTargetPorts(ctx, &sameRoute) if err != nil { r.logger.Error(err, "failed to get route target ports") + continue } for _, port := range ports { @@ -432,33 +444,66 @@ func (r *Reconciler) assembleHttpproxy(ctx context.Context, owner *routev1.Route func (r *Reconciler) getTargetPorts(ctx context.Context, route *routev1.Route) ([]int, error) { var ports []int + // servicePortName reflects the 'service.spec.ports.name' of the matched port. + servicePortName := "" targetPortName := "" targetPort := 0 + if route.Spec.Port != nil { targetPortName = route.Spec.Port.TargetPort.String() targetPort = route.Spec.Port.TargetPort.IntValue() } svc := &corev1.Service{} - if err := r.Get(ctx, types.NamespacedName{Namespace: route.Namespace, Name: route.Spec.To.Name}, svc); err != nil { - return ports, fmt.Errorf("failed to get route target service") + return ports, fmt.Errorf("failed to get route target service: %s", err.Error()) + } + + ep := &corev1.Endpoints{} + if err := r.Get(ctx, types.NamespacedName{Namespace: route.Namespace, Name: route.Spec.To.Name}, ep); err != nil { + return ports, fmt.Errorf("failed to get route target endpoint: %s", err.Error()) } + // OpenShift router has two different approaches to handle `route.spec.port.targetPort` based on its type: + // If the type is string, it will be looked up as a named port in the target endpoints port list to resolve the target port on pods. + // If the type is int, it must be the target port on pods selected by the service that route points to. + // However the httpproxy expects the service port not the pods port. to workaround the issue, + // we check the target endpoints port list as it covers both `route.spec.port.targetPort` types. + // This is handled by checking `targetPortName` against `endpoints.Subsets[i].Ports[j].Name` and `targetPort` against `endpoints.Subsets[i].Ports[j].Port` + // If a match (endpoint port) is found, its name will be used to resolve the service port as it matches the 'name' field in the corresponding ServicePort. +out: + for _, subnet := range ep.Subsets { + for _, port := range subnet.Ports { + if port.Protocol != corev1.ProtocolTCP { + continue + } + + if port.Name == targetPortName || port.Port == int32(targetPort) { + servicePortName = port.Name + break out + } + } + } + + // If a match is found, add it to a new array and break out of the loop. + // If no match is found, add all of the service TCP ports to the predefined array. for _, port := range svc.Spec.Ports { if port.Protocol != corev1.ProtocolTCP { continue } - if port.Name == targetPortName || port.Port == int32(targetPort) { + + if port.Name == servicePortName { ports = []int{int(port.Port)} break } + ports = append(ports, int(port.Port)) } if len(ports) == 0 { return ports, fmt.Errorf("no valid tcp ports found on the target service") } + return ports, nil } @@ -538,7 +583,7 @@ func (r *Reconciler) assembleTLSSecret(route *routev1.Route) *corev1.Secret { } } -// getSameHostRoutes returns routes with the same host as the given route in the same namespace +// getSameHostRoutes returns routes with the same host as the given httpproxy fqdn in the same namespace // the routes are sorted in ascending order by .metadata.creationTimestamp field func (r *Reconciler) getSameHostRoutes(ctx context.Context, namespace, host string) ([]routev1.Route, error) { sameHostRouteList := &routev1.RouteList{} diff --git a/pkg/utils/cert.go b/pkg/utils/cert.go new file mode 100644 index 0000000..622308f --- /dev/null +++ b/pkg/utils/cert.go @@ -0,0 +1,35 @@ +package utils + +import ( + "crypto/x509" + "encoding/pem" + "strings" +) + +func IsWildcardCertificate(cert string) (bool, error) { + // Decode the first PEM block (end-entity certificate) + block, _ := pem.Decode([]byte(cert)) + if block == nil { + return false, nil + } + + // Parse the end-entity certificate + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false, err + } + + // Check for wildcard in Common Name (CN) + if strings.HasPrefix(certificate.Subject.CommonName, "*.") { + return true, nil + } + + // Check for wildcards in Subject Alternative Names (SAN) + for _, dnsName := range certificate.DNSNames { + if strings.HasPrefix(dnsName, "*.") { + return true, nil + } + } + + return false, nil +}