diff --git a/config/samples/apisix.apache.org_v2_apisixconsumer.yaml b/config/samples/apisix.apache.org_v2_apisixconsumer.yaml index d5f4b99ed..b727f195a 100644 --- a/config/samples/apisix.apache.org_v2_apisixconsumer.yaml +++ b/config/samples/apisix.apache.org_v2_apisixconsumer.yaml @@ -1,4 +1,4 @@ -apiVersion: apisix.apache.org.github.com/v2 +apiVersion: apisix.apache.org/v2 kind: ApisixConsumer metadata: labels: diff --git a/config/samples/apisix.apache.org_v2_apisixglobalrule.yaml b/config/samples/apisix.apache.org_v2_apisixglobalrule.yaml index 8e88b42e3..3714bd005 100644 --- a/config/samples/apisix.apache.org_v2_apisixglobalrule.yaml +++ b/config/samples/apisix.apache.org_v2_apisixglobalrule.yaml @@ -1,4 +1,4 @@ -apiVersion: apisix.apache.org.github.com/v2 +apiVersion: apisix.apache.org/v2 kind: ApisixGlobalRule metadata: labels: diff --git a/config/samples/apisix.apache.org_v2_apisixpluginconfig.yaml b/config/samples/apisix.apache.org_v2_apisixpluginconfig.yaml index 8c7af2b1c..256787142 100644 --- a/config/samples/apisix.apache.org_v2_apisixpluginconfig.yaml +++ b/config/samples/apisix.apache.org_v2_apisixpluginconfig.yaml @@ -1,4 +1,4 @@ -apiVersion: apisix.apache.org.github.com/v2 +apiVersion: apisix.apache.org/v2 kind: ApisixPluginConfig metadata: labels: diff --git a/config/samples/apisix.apache.org_v2_apisixroute.yaml b/config/samples/apisix.apache.org_v2_apisixroute.yaml index 76be43858..c38ac6263 100644 --- a/config/samples/apisix.apache.org_v2_apisixroute.yaml +++ b/config/samples/apisix.apache.org_v2_apisixroute.yaml @@ -1,4 +1,4 @@ -apiVersion: apisix.apache.org.github.com/v2 +apiVersion: apisix.apache.org/v2 kind: ApisixRoute metadata: labels: diff --git a/config/samples/apisix.apache.org_v2_apisixtls.yaml b/config/samples/apisix.apache.org_v2_apisixtls.yaml index 8372ff95c..fe504d008 100644 --- a/config/samples/apisix.apache.org_v2_apisixtls.yaml +++ b/config/samples/apisix.apache.org_v2_apisixtls.yaml @@ -1,4 +1,4 @@ -apiVersion: apisix.apache.org.github.com/v2 +apiVersion: apisix.apache.org/v2 kind: ApisixTls metadata: labels: @@ -6,4 +6,18 @@ metadata: app.kubernetes.io/managed-by: kustomize name: apisixtls-sample spec: - # TODO(user): Add fields here + hosts: + - "example.com" + - "*.example.com" + secret: + name: "example-tls-secret" + namespace: "default" + # Optional: Mutual TLS configuration + # client: + # caSecret: + # name: "ca-secret" + # namespace: "default" + # depth: 2 + # skip_mtls_uri_regex: + # - "/health" + # - "/metrics" diff --git a/config/samples/apisix.apache.org_v2_apisixupstream.yaml b/config/samples/apisix.apache.org_v2_apisixupstream.yaml index fc95c6e31..c28664a33 100644 --- a/config/samples/apisix.apache.org_v2_apisixupstream.yaml +++ b/config/samples/apisix.apache.org_v2_apisixupstream.yaml @@ -1,4 +1,4 @@ -apiVersion: apisix.apache.org.github.com/v2 +apiVersion: apisix.apache.org/v2 kind: ApisixUpstream metadata: labels: diff --git a/internal/controller/apisixtls_controller.go b/internal/controller/apisixtls_controller.go index a34ed232f..08d631602 100644 --- a/internal/controller/apisixtls_controller.go +++ b/internal/controller/apisixtls_controller.go @@ -14,56 +14,468 @@ package controller import ( "context" + "errors" + "fmt" + "github.com/api7/gopkg/pkg/log" "github.com/go-logr/logr" + 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/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "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" + "github.com/apache/apisix-ingress-controller/internal/controller/status" + "github.com/apache/apisix-ingress-controller/internal/provider" + "github.com/apache/apisix-ingress-controller/internal/utils" ) // ApisixTlsReconciler reconciles a ApisixTls object type ApisixTlsReconciler struct { client.Client - Scheme *runtime.Scheme - Log logr.Logger + Scheme *runtime.Scheme + Log logr.Logger + Provider provider.Provider + Updater status.Updater } -// Reconcile FIXME: implement the reconcile logic (For now, it dose nothing other than directly accepting) +// SetupWithManager sets up the controller with the Manager. +func (r *ApisixTlsReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&apiv2.ApisixTls{}, + builder.WithPredicates( + predicate.NewPredicateFuncs(r.checkIngressClass), + ), + ). + WithEventFilter( + predicate.Or( + predicate.GenerationChangedPredicate{}, + predicate.AnnotationChangedPredicate{}, + ), + ). + Watches( + &networkingv1.IngressClass{}, + handler.EnqueueRequestsFromMapFunc(r.listApisixTlsForIngressClass), + builder.WithPredicates( + predicate.NewPredicateFuncs(r.matchesIngressController), + ), + ). + Watches(&v1alpha1.GatewayProxy{}, + handler.EnqueueRequestsFromMapFunc(r.listApisixTlsForGatewayProxy), + ). + Watches(&corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.listApisixTlsForSecret), + ). + Complete(r) +} + +// Reconcile implements the reconciliation logic for ApisixTls func (r *ApisixTlsReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - r.Log.Info("reconcile", "request", req.NamespacedName) + var tls apiv2.ApisixTls + if err := r.Get(ctx, req.NamespacedName, &tls); err != nil { + if client.IgnoreNotFound(err) == nil { + // Create a minimal object for deletion + tls.Namespace = req.Namespace + tls.Name = req.Name + tls.TypeMeta = metav1.TypeMeta{ + Kind: KindApisixTls, + APIVersion: apiv2.GroupVersion.String(), + } + // Delete from provider + if err := r.Provider.Delete(ctx, &tls); err != nil { + r.Log.Error(err, "failed to delete TLS from provider") + return ctrl.Result{}, err + } + r.Log.Info("deleted apisix tls", "tls", tls.Name) + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + r.Log.Info("reconciling apisix tls", "tls", tls.Name) + + // create a translate context + tctx := provider.NewDefaultTranslateContext(ctx) + + // get the ingress class + ingressClass, err := r.getIngressClass(&tls) + if err != nil { + log.Error(err, "failed to get IngressClass") + r.updateStatus(&tls, metav1.Condition{ + Type: string(apiv2.ConditionTypeAccepted), + Status: metav1.ConditionFalse, + ObservedGeneration: tls.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(apiv2.ConditionReasonInvalidSpec), + Message: err.Error(), + }) + return ctrl.Result{}, err + } - var obj apiv2.ApisixTls - if err := r.Get(ctx, req.NamespacedName, &obj); err != nil { - r.Log.Error(err, "failed to get ApisixConsumer", "request", req.NamespacedName) + // process IngressClass parameters if they reference GatewayProxy + if err := r.processIngressClassParameters(ctx, tctx, &tls, ingressClass); err != nil { + log.Error(err, "failed to process IngressClass parameters", "ingressClass", ingressClass.Name) + r.updateStatus(&tls, metav1.Condition{ + Type: string(apiv2.ConditionTypeAccepted), + Status: metav1.ConditionFalse, + ObservedGeneration: tls.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(apiv2.ConditionReasonInvalidSpec), + Message: err.Error(), + }) return ctrl.Result{}, err } - obj.Status.Conditions = []metav1.Condition{ - { - Type: string(gatewayv1.RouteConditionAccepted), - Status: metav1.ConditionTrue, - ObservedGeneration: obj.GetGeneration(), + // process ApisixTls validation + if err := r.processApisixTls(ctx, tctx, &tls); err != nil { + log.Error(err, "failed to process ApisixTls") + r.updateStatus(&tls, metav1.Condition{ + Type: string(apiv2.ConditionTypeAccepted), + Status: metav1.ConditionFalse, + ObservedGeneration: tls.Generation, LastTransitionTime: metav1.Now(), - Reason: string(gatewayv1.RouteReasonAccepted), - }, + Reason: string(apiv2.ConditionReasonInvalidSpec), + Message: err.Error(), + }) + return ctrl.Result{}, err } - if err := r.Status().Update(ctx, &obj); err != nil { - r.Log.Error(err, "failed to update status", "request", req.NamespacedName) + if err := r.Provider.Update(ctx, tctx, &tls); err != nil { + log.Error(err, "failed to sync apisix tls to provider") + // Update status with failure condition + r.updateStatus(&tls, metav1.Condition{ + Type: string(apiv2.ConditionTypeAccepted), + Status: metav1.ConditionFalse, + ObservedGeneration: tls.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(apiv2.ConditionReasonSyncFailed), + Message: err.Error(), + }) return ctrl.Result{}, err } + // Update status with success condition + r.updateStatus(&tls, metav1.Condition{ + Type: string(apiv2.ConditionTypeAccepted), + Status: metav1.ConditionTrue, + ObservedGeneration: tls.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(apiv2.ConditionReasonAccepted), + Message: "The apisix tls has been accepted and synced to APISIX", + }) + return ctrl.Result{}, nil } -// SetupWithManager sets up the controller with the Manager. -func (r *ApisixTlsReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&apiv2.ApisixTls{}). - Named("apisixtls"). - Complete(r) +func (r *ApisixTlsReconciler) processApisixTls(ctx context.Context, tc *provider.TranslateContext, tls *apiv2.ApisixTls) error { + // Validate the main TLS secret + if err := r.validateSecret(ctx, tc, tls.Spec.Secret); err != nil { + return fmt.Errorf("invalid apisix tls secret: %w", err) + } + + // Validate the client CA secret if mutual TLS is configured + if tls.Spec.Client != nil { + if err := r.validateSecret(ctx, tc, tls.Spec.Client.CASecret); err != nil { + return fmt.Errorf("invalid client CA secret: %w", err) + } + } + + return nil +} + +func (r *ApisixTlsReconciler) validateSecret(ctx context.Context, tc *provider.TranslateContext, secretRef apiv2.ApisixSecret) error { + secretKey := types.NamespacedName{ + Namespace: secretRef.Namespace, + Name: secretRef.Name, + } + + var secret corev1.Secret + if err := r.Get(ctx, secretKey, &secret); err != nil { + return fmt.Errorf("failed to get secret %s: %w", secretKey.String(), err) + } + + tc.Secrets[secretKey] = &secret + return nil +} + +// getIngressClass get the ingress class for the TLS +func (r *ApisixTlsReconciler) getIngressClass(tls *apiv2.ApisixTls) (*networkingv1.IngressClass, error) { + if tls.Spec.IngressClassName == "" { + // Check for default ingress class + ingressClassList := &networkingv1.IngressClassList{} + if err := r.List(context.Background(), ingressClassList, client.MatchingFields{ + indexer.IngressClass: config.GetControllerName(), + }); err != nil { + r.Log.Error(err, "failed to list ingress classes") + return nil, err + } + + // Find the ingress class that is marked as default + for _, ic := range ingressClassList.Items { + if IsDefaultIngressClass(&ic) && matchesController(ic.Spec.Controller) { + return &ic, nil + } + } + log.Debugw("no default ingress class found") + return nil, errors.New("no default ingress class found") + } + + // Check if the specified ingress class is controlled by us + var ingressClass networkingv1.IngressClass + if err := r.Get(context.Background(), client.ObjectKey{Name: tls.Spec.IngressClassName}, &ingressClass); err != nil { + return nil, err + } + + if matchesController(ingressClass.Spec.Controller) { + return &ingressClass, nil + } + + return nil, errors.New("ingress class is not controlled by us") +} + +// processIngressClassParameters processes the IngressClass parameters that reference GatewayProxy +func (r *ApisixTlsReconciler) processIngressClassParameters(ctx context.Context, tctx *provider.TranslateContext, tls *apiv2.ApisixTls, ingressClass *networkingv1.IngressClass) error { + if ingressClass == nil || ingressClass.Spec.Parameters == nil { + return nil + } + + ingressClassKind := utils.NamespacedNameKind(ingressClass) + tlsKind := utils.NamespacedNameKind(tls) + + parameters := ingressClass.Spec.Parameters + // check if the parameters reference GatewayProxy + if parameters.APIGroup != nil && *parameters.APIGroup == v1alpha1.GroupVersion.Group && parameters.Kind == KindGatewayProxy { + ns := tls.GetNamespace() + if parameters.Namespace != nil { + ns = *parameters.Namespace + } + + gatewayProxy := &v1alpha1.GatewayProxy{} + if err := r.Get(ctx, client.ObjectKey{ + Namespace: ns, + Name: parameters.Name, + }, gatewayProxy); err != nil { + r.Log.Error(err, "failed to get GatewayProxy", "namespace", ns, "name", parameters.Name) + return err + } + + r.Log.Info("found GatewayProxy for IngressClass", "ingressClass", ingressClass.Name, "gatewayproxy", gatewayProxy.Name) + tctx.GatewayProxies[ingressClassKind] = *gatewayProxy + tctx.ResourceParentRefs[tlsKind] = append(tctx.ResourceParentRefs[tlsKind], ingressClassKind) + + // check if the provider field references a secret + if gatewayProxy.Spec.Provider != nil && gatewayProxy.Spec.Provider.Type == v1alpha1.ProviderTypeControlPlane { + if gatewayProxy.Spec.Provider.ControlPlane != nil && + gatewayProxy.Spec.Provider.ControlPlane.Auth.Type == v1alpha1.AuthTypeAdminKey && + gatewayProxy.Spec.Provider.ControlPlane.Auth.AdminKey != nil && + gatewayProxy.Spec.Provider.ControlPlane.Auth.AdminKey.ValueFrom != nil && + gatewayProxy.Spec.Provider.ControlPlane.Auth.AdminKey.ValueFrom.SecretKeyRef != nil { + + secretRef := gatewayProxy.Spec.Provider.ControlPlane.Auth.AdminKey.ValueFrom.SecretKeyRef + secret := &corev1.Secret{} + if err := r.Get(ctx, client.ObjectKey{ + Namespace: ns, + Name: secretRef.Name, + }, secret); err != nil { + r.Log.Error(err, "failed to get secret for GatewayProxy provider", + "namespace", ns, + "name", secretRef.Name) + return err + } + + r.Log.Info("found secret for GatewayProxy provider", + "ingressClass", ingressClass.Name, + "gatewayproxy", gatewayProxy.Name, + "secret", secretRef.Name) + + tctx.Secrets[types.NamespacedName{ + Namespace: ns, + Name: secretRef.Name, + }] = secret + } + } + } + + return nil +} + +// updateStatus updates the ApisixTls status with the given condition +func (r *ApisixTlsReconciler) updateStatus(tls *apiv2.ApisixTls, condition metav1.Condition) { + r.Updater.Update(status.Update{ + NamespacedName: utils.NamespacedName(tls), + Resource: &apiv2.ApisixTls{}, + Mutator: status.MutatorFunc(func(obj client.Object) client.Object { + tlsCopy, ok := obj.(*apiv2.ApisixTls) + if !ok { + err := fmt.Errorf("unsupported object type %T", obj) + panic(err) + } + tlsResult := tlsCopy.DeepCopy() + tlsResult.Status.Conditions = []metav1.Condition{condition} + return tlsResult + }), + }) +} + +// checkIngressClass checks if the ApisixTls uses the ingress class that we control +func (r *ApisixTlsReconciler) checkIngressClass(obj client.Object) bool { + tls, ok := obj.(*apiv2.ApisixTls) + if !ok { + return false + } + + return r.matchesIngressClass(tls.Spec.IngressClassName) +} + +// matchesIngressClass checks if the given ingress class name matches our controlled classes +func (r *ApisixTlsReconciler) matchesIngressClass(ingressClassName string) bool { + if ingressClassName == "" { + // Check for default ingress class + ingressClassList := &networkingv1.IngressClassList{} + if err := r.List(context.Background(), ingressClassList, client.MatchingFields{ + indexer.IngressClass: config.GetControllerName(), + }); err != nil { + r.Log.Error(err, "failed to list ingress classes") + return false + } + + // Find the ingress class that is marked as default + for _, ic := range ingressClassList.Items { + if IsDefaultIngressClass(&ic) && matchesController(ic.Spec.Controller) { + return true + } + } + return false + } + + // Check if the specified ingress class is controlled by us + var ingressClass networkingv1.IngressClass + if err := r.Get(context.Background(), client.ObjectKey{Name: ingressClassName}, &ingressClass); err != nil { + r.Log.Error(err, "failed to get ingress class", "ingressClass", ingressClassName) + return false + } + + return matchesController(ingressClass.Spec.Controller) +} + +func (r *ApisixTlsReconciler) listApisixTlsForSecret(ctx context.Context, obj client.Object) []reconcile.Request { + secret, ok := obj.(*corev1.Secret) + if !ok { + return nil + } + + // Use index to find all ApisixTls that reference this secret + var tlsList apiv2.ApisixTlsList + if err := r.List(ctx, &tlsList, client.MatchingFields{ + indexer.SecretIndexRef: indexer.GenIndexKey(secret.Namespace, secret.Name), + }); err != nil { + r.Log.Error(err, "failed to list ApisixTls by secret index") + return nil + } + + requests := make([]reconcile.Request, 0, len(tlsList.Items)) + for _, tls := range tlsList.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: utils.NamespacedName(&tls), + }) + } + + return requests +} + +func (r *ApisixTlsReconciler) matchesIngressController(obj client.Object) bool { + ingressClass, ok := obj.(*networkingv1.IngressClass) + if !ok { + r.Log.Error(fmt.Errorf("unexpected object type"), "failed to convert object to IngressClass") + return false + } + return matchesController(ingressClass.Spec.Controller) +} + +// listApisixTlsForIngressClass list all TLS that use a specific ingress class +func (r *ApisixTlsReconciler) listApisixTlsForIngressClass(ctx context.Context, obj client.Object) []reconcile.Request { + ingressClass, ok := obj.(*networkingv1.IngressClass) + if !ok { + return nil + } + + // Use index to find all ApisixTls that reference this ingress class + tlsList := &apiv2.ApisixTlsList{} + requests := make([]reconcile.Request, 0, len(tlsList.Items)) + if err := r.List(ctx, tlsList, client.MatchingFields{ + indexer.IngressClassRef: ingressClass.Name, + }); err != nil { + r.Log.Error(err, "failed to list ApisixTls by ingress class index") + return nil + } + + for _, tls := range tlsList.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: utils.NamespacedName(&tls), + }) + } + + // If this is the default ingress class, also find TLS with empty ingress class + if IsDefaultIngressClass(ingressClass) { + var tlsListWithoutClass apiv2.ApisixTlsList + if err := r.List(ctx, &tlsListWithoutClass); err != nil { + r.Log.Error(err, "failed to list all ApisixTls") + return requests + } + + for _, tls := range tlsListWithoutClass.Items { + if tls.Spec.IngressClassName == "" { + requests = append(requests, reconcile.Request{ + NamespacedName: utils.NamespacedName(&tls), + }) + } + } + } + + return requests +} + +// listApisixTlsForGatewayProxy list all TLS that use a specific gateway proxy +func (r *ApisixTlsReconciler) listApisixTlsForGatewayProxy(ctx context.Context, obj client.Object) []reconcile.Request { + gatewayProxy, ok := obj.(*v1alpha1.GatewayProxy) + if !ok { + return nil + } + + // Find all ingress classes that reference this gateway proxy + ingressClassList := &networkingv1.IngressClassList{} + if err := r.List(ctx, ingressClassList, client.MatchingFields{ + indexer.IngressClassParametersRef: indexer.GenIndexKey(gatewayProxy.GetNamespace(), gatewayProxy.GetName()), + }); err != nil { + r.Log.Error(err, "failed to list ingress classes for gateway proxy", "gatewayproxy", gatewayProxy.GetName()) + return nil + } + + var requests []reconcile.Request + for _, ingressClass := range ingressClassList.Items { + requests = append(requests, r.listApisixTlsForIngressClass(ctx, &ingressClass)...) + } + + // Remove duplicates + uniqueRequests := make(map[string]reconcile.Request) + for _, request := range requests { + uniqueRequests[request.String()] = request + } + + distinctRequests := make([]reconcile.Request, 0, len(uniqueRequests)) + for _, request := range uniqueRequests { + distinctRequests = append(distinctRequests, request) + } + + return distinctRequests } diff --git a/internal/controller/indexer/indexer.go b/internal/controller/indexer/indexer.go index 252931185..1f0354aa0 100644 --- a/internal/controller/indexer/indexer.go +++ b/internal/controller/indexer/indexer.go @@ -56,6 +56,7 @@ func SetupIndexer(mgr ctrl.Manager) error { setupGatewayClassIndexer, setupApisixRouteIndexer, setupApisixPluginConfigIndexer, + setupApisixTlsIndexer, } { if err := setup(mgr); err != nil { return err @@ -638,3 +639,52 @@ func ApisixPluginConfigSecretIndexFunc(obj client.Object) (keys []string) { } return } + +func setupApisixTlsIndexer(mgr ctrl.Manager) error { + // Create secret index for ApisixTls + if err := mgr.GetFieldIndexer().IndexField( + context.Background(), + &apiv2.ApisixTls{}, + SecretIndexRef, + ApisixTlsSecretIndexFunc, + ); err != nil { + return err + } + + // Create ingress class index for ApisixTls + if err := mgr.GetFieldIndexer().IndexField( + context.Background(), + &apiv2.ApisixTls{}, + IngressClassRef, + ApisixTlsIngressClassIndexFunc, + ); err != nil { + return err + } + + return nil +} + +func ApisixTlsSecretIndexFunc(rawObj client.Object) []string { + tls := rawObj.(*apiv2.ApisixTls) + secrets := make([]string, 0) + + // Index the main TLS secret + key := GenIndexKey(tls.Spec.Secret.Namespace, tls.Spec.Secret.Name) + secrets = append(secrets, key) + + // Index the client CA secret if mutual TLS is configured + if tls.Spec.Client != nil { + caKey := GenIndexKey(tls.Spec.Client.CASecret.Namespace, tls.Spec.Client.CASecret.Name) + secrets = append(secrets, caKey) + } + + return secrets +} + +func ApisixTlsIngressClassIndexFunc(rawObj client.Object) []string { + tls := rawObj.(*apiv2.ApisixTls) + if tls.Spec.IngressClassName == "" { + return nil + } + return []string{tls.Spec.IngressClassName} +} diff --git a/internal/controller/utils.go b/internal/controller/utils.go index e4a2c8bd0..6be766041 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -58,6 +58,7 @@ const ( KindApisixRoute = "ApisixRoute" KindApisixGlobalRule = "ApisixGlobalRule" KindApisixPluginConfig = "ApisixPluginConfig" + KindApisixTls = "ApisixTls" ) const defaultIngressClassAnnotation = "ingressclass.kubernetes.io/is-default-class" diff --git a/internal/manager/controllers.go b/internal/manager/controllers.go index 0a52499c2..5fb93bbcd 100644 --- a/internal/manager/controllers.go +++ b/internal/manager/controllers.go @@ -140,6 +140,13 @@ func setupControllers(ctx context.Context, mgr manager.Manager, pro provider.Pro Log: ctrl.LoggerFrom(ctx).WithName("controllers").WithName("ApisixPluginConfig"), Updater: updater, }, + &controller.ApisixTlsReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Log: ctrl.LoggerFrom(ctx).WithName("controllers").WithName("ApisixTls"), + Provider: pro, + Updater: updater, + }, &controller.ApisixUpstreamReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/internal/provider/adc/adc.go b/internal/provider/adc/adc.go index ca80f06e8..0b426feb7 100644 --- a/internal/provider/adc/adc.go +++ b/internal/provider/adc/adc.go @@ -122,6 +122,9 @@ func (d *adcClient) Update(ctx context.Context, tctx *provider.TranslateContext, case *apiv2.ApisixGlobalRule: result, err = d.translator.TranslateApisixGlobalRule(tctx, t.DeepCopy()) resourceTypes = append(resourceTypes, "global_rule") + case *apiv2.ApisixTls: + result, err = d.translator.TranslateApisixTls(tctx, t.DeepCopy()) + resourceTypes = append(resourceTypes, "ssl") } if err != nil { return err @@ -217,6 +220,9 @@ func (d *adcClient) Delete(ctx context.Context, obj client.Object) error { case *apiv2.ApisixGlobalRule: resourceTypes = append(resourceTypes, "global_rule") labels = label.GenLabel(obj) + case *apiv2.ApisixTls: + resourceTypes = append(resourceTypes, "ssl") + labels = label.GenLabel(obj) } rk := utils.NamespacedNameKind(obj) diff --git a/internal/provider/adc/translator/apisixtls.go b/internal/provider/adc/translator/apisixtls.go new file mode 100644 index 000000000..49d965327 --- /dev/null +++ b/internal/provider/adc/translator/apisixtls.go @@ -0,0 +1,92 @@ +// Licensed 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 translator + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/types" + + adctypes "github.com/apache/apisix-ingress-controller/api/adc" + apiv2 "github.com/apache/apisix-ingress-controller/api/v2" + "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" +) + +func (t *Translator) TranslateApisixTls(tctx *provider.TranslateContext, tls *apiv2.ApisixTls) (*TranslateResult, error) { + result := &TranslateResult{} + + // Get the secret from the context + secretKey := types.NamespacedName{ + Namespace: tls.Spec.Secret.Namespace, + Name: tls.Spec.Secret.Name, + } + secret, ok := tctx.Secrets[secretKey] + if !ok || secret == nil { + return nil, fmt.Errorf("secret %s not found", secretKey.String()) + } + + // Extract cert and key from secret + cert, key, err := extractKeyPair(secret, true) + if err != nil { + return nil, err + } + + // Convert hosts to strings + snis := make([]string, len(tls.Spec.Hosts)) + for i, host := range tls.Spec.Hosts { + snis[i] = string(host) + } + + // Create SSL object + ssl := &adctypes.SSL{ + Metadata: adctypes.Metadata{ + ID: id.GenID(tls.Namespace + "_" + tls.Name), + Labels: label.GenLabel(tls), + }, + Certificates: []adctypes.Certificate{ + { + Certificate: string(cert), + Key: string(key), + }, + }, + Snis: snis, + } + + // Handle mutual TLS client configuration if present + if tls.Spec.Client != nil { + caSecretKey := types.NamespacedName{ + Namespace: tls.Spec.Client.CASecret.Namespace, + Name: tls.Spec.Client.CASecret.Name, + } + caSecret, ok := tctx.Secrets[caSecretKey] + if !ok || caSecret == nil { + return nil, fmt.Errorf("client CA secret %s not found", caSecretKey.String()) + } + + ca, _, err := extractKeyPair(caSecret, false) + if err != nil { + return nil, err + } + depth := int64(tls.Spec.Client.Depth) + ssl.Client = &adctypes.ClientClass{ + CA: string(ca), + Depth: &depth, + SkipMtlsURIRegex: tls.Spec.Client.SkipMTLSUriRegex, + } + } + + result.SSL = append(result.SSL, ssl) + return result, nil +} diff --git a/test/e2e/apisix/tls.go b/test/e2e/apisix/tls.go new file mode 100644 index 000000000..4fb950dfa --- /dev/null +++ b/test/e2e/apisix/tls.go @@ -0,0 +1,259 @@ +// Licensed 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 apisix + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/types" + + apiv2 "github.com/apache/apisix-ingress-controller/api/v2" + "github.com/apache/apisix-ingress-controller/test/e2e/framework" + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" +) + +const gatewayProxyYamlTls = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: apisix-proxy-tls + namespace: default +spec: + provider: + type: ControlPlane + controlPlane: + endpoints: + - %s + auth: + type: AdminKey + adminKey: + value: "%s" +` + +const ingressClassYamlTls = ` +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + name: apisix-tls +spec: + controller: "apisix.apache.org/apisix-ingress-controller" + parameters: + apiGroup: "apisix.apache.org" + kind: "GatewayProxy" + name: "apisix-proxy-tls" + namespace: "default" + scope: "Namespace" +` + +const apisixRouteYamlTls = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route-tls +spec: + ingressClassName: apisix-tls + http: + - name: rule0 + match: + paths: + - /* + hosts: + - api6.com + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 +` + +var Cert = strings.TrimSpace(framework.TestServerCert) + +var Key = strings.TrimSpace(framework.TestServerKey) + +var _ = Describe("Test ApisixTls", func() { + var ( + s = scaffold.NewScaffold(&scaffold.Options{ + ControllerName: "apisix.apache.org/apisix-ingress-controller", + }) + applier = framework.NewApplier(s.GinkgoT, s.K8sClient, s.CreateResourceFromString) + ) + + Context("Test ApisixTls", func() { + BeforeEach(func() { + By("create GatewayProxy") + gatewayProxy := fmt.Sprintf(gatewayProxyYamlTls, s.Deployer.GetAdminEndpoint(), s.AdminKey()) + err := s.CreateResourceFromStringWithNamespace(gatewayProxy, "default") + Expect(err).NotTo(HaveOccurred(), "creating GatewayProxy") + time.Sleep(5 * time.Second) + + By("create IngressClass") + err = s.CreateResourceFromStringWithNamespace(ingressClassYamlTls, "") + Expect(err).NotTo(HaveOccurred(), "creating IngressClass") + time.Sleep(5 * time.Second) + + By("create ApisixRoute for TLS testing") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route-tls"}, &apisixRoute, apisixRouteYamlTls) + }) + + AfterEach(func() { + By("delete GatewayProxy") + gatewayProxy := fmt.Sprintf(gatewayProxyYamlTls, s.Deployer.GetAdminEndpoint(), s.AdminKey()) + err := s.DeleteResourceFromStringWithNamespace(gatewayProxy, "default") + Expect(err).ShouldNot(HaveOccurred(), "deleting GatewayProxy") + + By("delete IngressClass") + err = s.DeleteResourceFromStringWithNamespace(ingressClassYamlTls, "") + Expect(err).ShouldNot(HaveOccurred(), "deleting IngressClass") + }) + + It("Basic ApisixTls test", func() { + const host = "api6.com" + + By("create TLS secret") + err := s.NewKubeTlsSecret("test-tls-secret", Cert, Key) + Expect(err).NotTo(HaveOccurred(), "creating TLS secret") + + const apisixTlsSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: test-tls +spec: + ingressClassName: apisix-tls + hosts: + - api6.com + secret: + name: test-tls-secret + namespace: %s +` + + By("apply ApisixTls") + var apisixTls apiv2.ApisixTls + tlsSpec := fmt.Sprintf(apisixTlsSpec, s.Namespace()) + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-tls"}, &apisixTls, tlsSpec) + + By("verify TLS configuration in control plane") + Eventually(func() bool { + tls, err := s.DefaultDataplaneResource().SSL().List(context.Background()) + if err != nil { + return false + } + if len(tls) != 1 { + return false + } + if len(tls[0].Certificates) != 1 { + return false + } + return true + }).WithTimeout(30 * time.Second).ProbeEvery(2 * time.Second).Should(BeTrue()) + + tls, err := s.DefaultDataplaneResource().SSL().List(context.Background()) + assert.Nil(GinkgoT(), err, "list tls error") + assert.Len(GinkgoT(), tls, 1, "tls number not expect") + assert.Len(GinkgoT(), tls[0].Certificates, 1, "length of certificates not expect") + assert.Equal(GinkgoT(), Cert, tls[0].Certificates[0].Certificate, "tls cert not expect") + assert.ElementsMatch(GinkgoT(), []string{host}, tls[0].Snis) + + By("test HTTPS request to dataplane") + Eventually(func() int { + return s.NewAPISIXHttpsClient("api6.com"). + GET("/get"). + WithHost("api6.com"). + Expect(). + Raw().StatusCode + }).WithTimeout(30 * time.Second).ProbeEvery(2 * time.Second).Should(Equal(http.StatusOK)) + + s.NewAPISIXHttpsClient("api6.com"). + GET("/get"). + WithHost("api6.com"). + Expect(). + Status(200) + }) + + It("ApisixTls with mTLS test", func() { + const host = "api6.com" + + By("generate mTLS certificates") + caCertBytes, serverCertBytes, serverKeyBytes, _, _ := s.GenerateMACert(GinkgoT(), []string{host}) + caCert := caCertBytes.String() + serverCert := serverCertBytes.String() + serverKey := serverKeyBytes.String() + + By("create TLS secret") + err := s.NewKubeTlsSecret("test-mtls-secret", serverCert, serverKey) + Expect(err).NotTo(HaveOccurred(), "creating TLS secret") + + By("create CA secret") + err = s.NewClientCASecret("test-ca-secret", caCert, "") + Expect(err).NotTo(HaveOccurred(), "creating CA secret") + + const apisixTlsSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: test-mtls +spec: + ingressClassName: apisix-tls + hosts: + - api6.com + secret: + name: test-mtls-secret + namespace: %s + client: + caSecret: + name: test-ca-secret + namespace: %s + depth: 1 +` + + By("apply ApisixTls with mTLS") + var apisixTls apiv2.ApisixTls + tlsSpec := fmt.Sprintf(apisixTlsSpec, s.Namespace(), s.Namespace()) + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-mtls"}, &apisixTls, tlsSpec) + + By("verify mTLS configuration in control plane") + Eventually(func() bool { + tls, err := s.DefaultDataplaneResource().SSL().List(context.Background()) + if err != nil { + return false + } + if len(tls) != 1 { + return false + } + if len(tls[0].Certificates) != 1 { + return false + } + // Check if client CA is configured + return tls[0].Client != nil && tls[0].Client.CA != "" + }).WithTimeout(30 * time.Second).ProbeEvery(2 * time.Second).Should(BeTrue()) + + tls, err := s.DefaultDataplaneResource().SSL().List(context.Background()) + assert.Nil(GinkgoT(), err, "list tls error") + assert.Len(GinkgoT(), tls, 1, "tls number not expect") + assert.Len(GinkgoT(), tls[0].Certificates, 1, "length of certificates not expect") + assert.Equal(GinkgoT(), serverCert, tls[0].Certificates[0].Certificate, "tls cert not expect") + assert.ElementsMatch(GinkgoT(), []string{host}, tls[0].Snis) + assert.NotNil(GinkgoT(), tls[0].Client, "client configuration should not be nil") + assert.NotEmpty(GinkgoT(), tls[0].Client.CA, "client CA should not be empty") + assert.Equal(GinkgoT(), caCert, tls[0].Client.CA, "client CA should be test-ca-secret") + assert.Equal(GinkgoT(), int64(1), *tls[0].Client.Depth, "client depth should be 1") + }) + + }) +})