package controller import ( "context" "errors" "fmt" "strings" "time" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" 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/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/reconcile" mikrotikv1alpha1 "code.brunner.ninja/feedc0de/mikrotik-operator/api/v1alpha1" "code.brunner.ninja/feedc0de/mikrotik-operator/internal/routeros" ) const ( syncInterval = 2 * time.Minute routerFinalizer = "mikrotik.brunner.ninja/nat-cleanup" ) type RouterClientFactory func(endpoint, username, password string, tlsConfig routeros.TLSConfig) (routeros.API, error) type MikroTikRouterReconciler struct { client.Client Scheme *runtime.Scheme ClientFactory RouterClientFactory } func (r *MikroTikRouterReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) { var router mikrotikv1alpha1.MikroTikRouter if err := r.Get(ctx, request.NamespacedName, &router); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } if !router.DeletionTimestamp.IsZero() { return r.finalize(ctx, &router) } if !controllerutil.ContainsFinalizer(&router, routerFinalizer) { controllerutil.AddFinalizer(&router, routerFinalizer) if err := r.Update(ctx, &router); err != nil { return ctrl.Result{}, fmt.Errorf("add cleanup finalizer: %w", err) } return ctrl.Result{Requeue: true}, nil } api, err := r.routerClient(ctx, &router) if err != nil { if statusErr := r.setStatus(ctx, &router, 0, metav1.ConditionFalse, "ConfigurationError", err.Error()); statusErr != nil { return ctrl.Result{}, errors.Join(err, statusErr) } return ctrl.Result{RequeueAfter: syncInterval}, nil } var serviceList corev1.ServiceList if err := r.List(ctx, &serviceList); err != nil { return ctrl.Result{}, fmt.Errorf("list Services: %w", err) } desired, preserve, issues := DesiredRules(&router, serviceList.Items) managedRules, err := api.Sync(ctx, desired, preserve) if err != nil { if statusErr := r.setStatus(ctx, &router, router.Status.ManagedRules, metav1.ConditionFalse, "RouterAPIError", err.Error()); statusErr != nil { return ctrl.Result{}, errors.Join(err, statusErr) } return ctrl.Result{}, err } if len(issues) > 0 { message := issueMessage(issues) if err := r.setStatus(ctx, &router, int32(managedRules), metav1.ConditionFalse, "ServiceConfigurationError", message); err != nil { return ctrl.Result{}, err } return ctrl.Result{RequeueAfter: syncInterval}, nil } if err := r.setStatus(ctx, &router, int32(managedRules), metav1.ConditionTrue, "Synchronized", "RouterOS NAT rules are synchronized"); err != nil { return ctrl.Result{}, err } return ctrl.Result{RequeueAfter: syncInterval}, nil } func (r *MikroTikRouterReconciler) finalize(ctx context.Context, router *mikrotikv1alpha1.MikroTikRouter) (ctrl.Result, error) { if !controllerutil.ContainsFinalizer(router, routerFinalizer) { return ctrl.Result{}, nil } api, err := r.routerClient(ctx, router) if err != nil { return ctrl.Result{}, fmt.Errorf("initialize RouterOS client for cleanup: %w", err) } if _, err := api.Sync(ctx, nil, map[string]struct{}{}); err != nil { return ctrl.Result{}, fmt.Errorf("remove managed NAT rules during router deletion: %w", err) } controllerutil.RemoveFinalizer(router, routerFinalizer) if err := r.Update(ctx, router); err != nil { return ctrl.Result{}, fmt.Errorf("remove cleanup finalizer: %w", err) } return ctrl.Result{}, nil } func (r *MikroTikRouterReconciler) routerClient(ctx context.Context, router *mikrotikv1alpha1.MikroTikRouter) (routeros.API, error) { ref := router.Spec.CredentialsSecretRef var credentials corev1.Secret if err := r.Get(ctx, types.NamespacedName{Namespace: ref.Namespace, Name: ref.Name}, &credentials); err != nil { if apierrors.IsNotFound(err) { return nil, fmt.Errorf("credentials Secret %s/%s does not exist", ref.Namespace, ref.Name) } return nil, fmt.Errorf("read credentials Secret: %w", err) } username, usernameOK := credentials.Data["username"] password, passwordOK := credentials.Data["password"] if !usernameOK || !passwordOK { return nil, errors.New("credentials Secret must contain username and password keys") } if len(username) == 0 || len(password) == 0 { return nil, errors.New("credentials Secret username and password must not be empty") } tlsConfig := routeros.TLSConfig{InsecureSkipVerify: router.Spec.TLS.InsecureSkipVerify} if caRef := router.Spec.TLS.CASecretRef; caRef != nil { var caSecret corev1.Secret if err := r.Get(ctx, types.NamespacedName{Namespace: caRef.Namespace, Name: caRef.Name}, &caSecret); err != nil { return nil, fmt.Errorf("read CA Secret %s/%s: %w", caRef.Namespace, caRef.Name, err) } var ok bool tlsConfig.CABundle, ok = caSecret.Data["ca.crt"] if !ok { return nil, fmt.Errorf("CA Secret %s/%s must contain ca.crt", caRef.Namespace, caRef.Name) } } return r.ClientFactory(router.Spec.Endpoint, string(username), string(password), tlsConfig) } func (r *MikroTikRouterReconciler) setStatus(ctx context.Context, router *mikrotikv1alpha1.MikroTikRouter, count int32, conditionStatus metav1.ConditionStatus, reason, message string) error { now := metav1.Now() router.Status.ObservedGeneration = router.Generation router.Status.ManagedRules = count router.Status.LastSyncTime = &now meta.SetStatusCondition(&router.Status.Conditions, metav1.Condition{ Type: "Ready", Status: conditionStatus, Reason: reason, Message: message, ObservedGeneration: router.Generation, }) return r.Status().Update(ctx, router) } func issueMessage(issues []RuleIssue) string { const maxIssues = 3 messages := make([]string, 0, min(len(issues), maxIssues)) for i, issue := range issues { if i == maxIssues { break } messages = append(messages, issue.Service+": "+issue.Message) } if len(issues) > maxIssues { messages = append(messages, fmt.Sprintf("and %d more", len(issues)-maxIssues)) } return strings.Join(messages, "; ") } func (r *MikroTikRouterReconciler) enqueueAllRouters(ctx context.Context, _ client.Object) []reconcile.Request { var routers mikrotikv1alpha1.MikroTikRouterList if err := r.List(ctx, &routers); err != nil { return nil } requests := make([]reconcile.Request, 0, len(routers.Items)) for i := range routers.Items { requests = append(requests, reconcile.Request{NamespacedName: types.NamespacedName{Name: routers.Items[i].Name}}) } return requests } func (r *MikroTikRouterReconciler) SetupWithManager(manager ctrl.Manager) error { if r.ClientFactory == nil { r.ClientFactory = func(endpoint, username, password string, tlsConfig routeros.TLSConfig) (routeros.API, error) { return routeros.NewClient(endpoint, username, password, tlsConfig) } } return ctrl.NewControllerManagedBy(manager). For(&mikrotikv1alpha1.MikroTikRouter{}). Watches(&corev1.Service{}, handler.EnqueueRequestsFromMapFunc(r.enqueueAllRouters)). Complete(r) }