diff --git a/api/v1beta1/headscalepreauthkey_types.go b/api/v1beta1/headscalepreauthkey_types.go index ea877f2..bf00b8b 100644 --- a/api/v1beta1/headscalepreauthkey_types.go +++ b/api/v1beta1/headscalepreauthkey_types.go @@ -74,6 +74,11 @@ type HeadscalePreAuthKeyStatus struct { // +optional KeyID string `json:"keyId,omitempty"` + // ExpiresAt is the absolute time when the preauth key expires. + // Computed by the controller from spec.Expiration at key creation time. + // +optional + ExpiresAt *metav1.Time `json:"expiresAt,omitempty"` + // conditions represent the current state of the HeadscalePreAuthKey resource. // +listType=map // +listMapKey=type @@ -88,6 +93,8 @@ type HeadscalePreAuthKeyStatus struct { // +kubebuilder:printcolumn:name="UserID",type=integer,JSONPath=`.spec.userId` // +kubebuilder:printcolumn:name="Reusable",type=boolean,JSONPath=`.spec.reusable` // +kubebuilder:printcolumn:name="Ephemeral",type=boolean,JSONPath=`.spec.ephemeral` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status` +// +kubebuilder:printcolumn:name="ExpiresAt",type=date,JSONPath=`.status.expiresAt` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:validation:XValidation:rule="(has(self.spec.headscaleUserRef) && self.spec.headscaleUserRef != \"\") != (has(self.spec.userId) && self.spec.userId != 0)",message="exactly one of spec.headscaleUserRef or spec.userId must be specified" diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 1ba22e2..9794e32 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -428,6 +428,10 @@ func (in *HeadscalePreAuthKeySpec) DeepCopy() *HeadscalePreAuthKeySpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HeadscalePreAuthKeyStatus) DeepCopyInto(out *HeadscalePreAuthKeyStatus) { *out = *in + if in.ExpiresAt != nil { + in, out := &in.ExpiresAt, &out.ExpiresAt + *out = (*in).DeepCopy() + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]v1.Condition, len(*in)) diff --git a/config/crd/bases/headscale.infrado.cloud_headscalepreauthkeys.yaml b/config/crd/bases/headscale.infrado.cloud_headscalepreauthkeys.yaml index 91b347a..cba1e7f 100644 --- a/config/crd/bases/headscale.infrado.cloud_headscalepreauthkeys.yaml +++ b/config/crd/bases/headscale.infrado.cloud_headscalepreauthkeys.yaml @@ -30,6 +30,12 @@ spec: - jsonPath: .spec.ephemeral name: Ephemeral type: boolean + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Status + type: string + - jsonPath: .status.expiresAt + name: ExpiresAt + type: date - jsonPath: .metadata.creationTimestamp name: Age type: date @@ -173,6 +179,12 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + expiresAt: + description: |- + ExpiresAt is the absolute time when the preauth key expires. + Computed by the controller from spec.Expiration at key creation time. + format: date-time + type: string keyId: description: KeyID is the ID of the preauth key in Headscale type: string diff --git a/internal/controller/headscalepreauthkey_controller.go b/internal/controller/headscalepreauthkey_controller.go index afdaf64..2688bd7 100644 --- a/internal/controller/headscalepreauthkey_controller.go +++ b/internal/controller/headscalepreauthkey_controller.go @@ -205,16 +205,7 @@ func (r *HeadscalePreAuthKeyReconciler) Reconcile(ctx context.Context, req ctrl. // Check if the preauth key already exists if preAuthKey.Status.KeyID != "" { - // PreAuth key already created - if err := r.updateStatusCondition(ctx, preAuthKey, metav1.Condition{ - Type: "Ready", - Status: metav1.ConditionTrue, - Reason: "PreAuthKeyReady", - Message: "PreAuth key is ready", - }); err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{}, nil + return r.reconcileExistingKey(ctx, preAuthKey) } // Create the preauth key in Headscale @@ -249,6 +240,43 @@ func (r *HeadscalePreAuthKeyReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } +// reconcileExistingKey handles reconciliation when a preauth key already exists (KeyID is set). +// It checks expiration status and requeues before expiration for active keys. +func (r *HeadscalePreAuthKeyReconciler) reconcileExistingKey( + ctx context.Context, + preAuthKey *headscalev1beta1.HeadscalePreAuthKey, +) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + if preAuthKey.Status.ExpiresAt != nil && time.Now().After(preAuthKey.Status.ExpiresAt.Time) { + log.Info("PreAuth key has expired", "keyID", preAuthKey.Status.KeyID, "expiresAt", preAuthKey.Status.ExpiresAt.Time) + if err := r.updateStatusCondition(ctx, preAuthKey, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + Reason: "KeyExpired", + Message: fmt.Sprintf("PreAuth key expired at %s", preAuthKey.Status.ExpiresAt.Format(time.RFC3339)), + }); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + // Key is ready, requeue before expiration if ExpiresAt is set + var requeueAfter time.Duration + if preAuthKey.Status.ExpiresAt != nil { + requeueAfter = time.Until(preAuthKey.Status.ExpiresAt.Time) + } + if err := r.updateStatusCondition(ctx, preAuthKey, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "PreAuthKeyReady", + Message: "PreAuth key is ready", + }); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: requeueAfter}, nil +} + // handleDeletion handles the deletion of a HeadscalePreAuthKey instance func (r *HeadscalePreAuthKeyReconciler) handleDeletion( ctx context.Context, @@ -459,6 +487,8 @@ func (r *HeadscalePreAuthKeyReconciler) createPreAuthKey( // Update status with preauth key information preAuthKey.Status.KeyID = strconv.FormatUint(key.GetId(), 10) + expiresAt := metav1.NewTime(time.Now().Add(expiration)) + preAuthKey.Status.ExpiresAt = &expiresAt if err := r.Status().Update(ctx, preAuthKey); err != nil { return fmt.Errorf("failed to update HeadscalePreAuthKey status: %w", err) diff --git a/internal/controller/headscalepreauthkey_controller_test.go b/internal/controller/headscalepreauthkey_controller_test.go index 24807d0..7c4638b 100644 --- a/internal/controller/headscalepreauthkey_controller_test.go +++ b/internal/controller/headscalepreauthkey_controller_test.go @@ -713,6 +713,134 @@ var _ = Describe("HeadscalePreAuthKey Controller", func() { By("Cleaning up the test resource") Expect(k8sClient.Delete(ctx, preAuthKey)).To(Succeed()) }) + + It("should set Ready=False when preauth key has expired", func() { + By("Creating a HeadscalePreAuthKey") + preAuthKey := &headscalev1beta1.HeadscalePreAuthKey{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName + "-expired", + Namespace: namespace, + }, + Spec: headscalev1beta1.HeadscalePreAuthKeySpec{ + HeadscaleRef: headscaleName, + HeadscaleUserRef: headscaleUserName, + Expiration: "1h", + }, + } + Expect(k8sClient.Create(ctx, preAuthKey)).To(Succeed()) + + expiredNamespacedName := types.NamespacedName{ + Name: resourceName + "-expired", + Namespace: namespace, + } + + By("Setting KeyID and ExpiresAt in the past to simulate an expired key") + expiredTime := metav1.NewTime(time.Now().Add(-1 * time.Hour)) + Eventually(func() error { + err := k8sClient.Get(ctx, expiredNamespacedName, preAuthKey) + if err != nil { + return err + } + preAuthKey.Status.KeyID = "789" + preAuthKey.Status.ExpiresAt = &expiredTime + return k8sClient.Status().Update(ctx, preAuthKey) + }, timeout, interval).Should(Succeed()) + + By("Reconciling the resource") + controllerReconciler := &HeadscalePreAuthKeyReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: expiredNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Checking that the status reflects the expired key") + Eventually(func() bool { + err := k8sClient.Get(ctx, expiredNamespacedName, preAuthKey) + if err != nil { + return false + } + for _, condition := range preAuthKey.Status.Conditions { + if condition.Type == readyConditionType && + condition.Status == metav1.ConditionFalse && + condition.Reason == "KeyExpired" { + return true + } + } + return false + }, timeout, interval).Should(BeTrue()) + + By("Cleaning up the test resource") + Expect(k8sClient.Delete(ctx, preAuthKey)).To(Succeed()) + }) + + It("should requeue before expiration for active preauth key", func() { + By("Creating a HeadscalePreAuthKey") + preAuthKey := &headscalev1beta1.HeadscalePreAuthKey{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName + "-active", + Namespace: namespace, + }, + Spec: headscalev1beta1.HeadscalePreAuthKeySpec{ + HeadscaleRef: headscaleName, + HeadscaleUserRef: headscaleUserName, + Expiration: "1h", + }, + } + Expect(k8sClient.Create(ctx, preAuthKey)).To(Succeed()) + + activeNamespacedName := types.NamespacedName{ + Name: resourceName + "-active", + Namespace: namespace, + } + + By("Setting KeyID and ExpiresAt in the future to simulate an active key") + futureTime := metav1.NewTime(time.Now().Add(1 * time.Hour)) + Eventually(func() error { + err := k8sClient.Get(ctx, activeNamespacedName, preAuthKey) + if err != nil { + return err + } + preAuthKey.Status.KeyID = "789" + preAuthKey.Status.ExpiresAt = &futureTime + return k8sClient.Status().Update(ctx, preAuthKey) + }, timeout, interval).Should(Succeed()) + + By("Reconciling the resource") + controllerReconciler := &HeadscalePreAuthKeyReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + result, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: activeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Checking that reconcile requested requeue before expiration") + Expect(result.RequeueAfter).To(BeNumerically(">", 0)) + + By("Checking that the status is Ready=True") + Eventually(func() bool { + err := k8sClient.Get(ctx, activeNamespacedName, preAuthKey) + if err != nil { + return false + } + for _, condition := range preAuthKey.Status.Conditions { + if condition.Type == readyConditionType && + condition.Status == metav1.ConditionTrue { + return true + } + } + return false + }, timeout, interval).Should(BeTrue()) + + By("Cleaning up the test resource") + Expect(k8sClient.Delete(ctx, preAuthKey)).To(Succeed()) + }) }) Context("Helper function tests", func() {