189 lines
7.1 KiB
Go
189 lines
7.1 KiB
Go
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)
|
|
}
|