Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/v1beta1/headscalepreauthkey_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand Down
4 changes: 4 additions & 0 deletions api/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions config/crd/bases/headscale.infrado.cloud_headscalepreauthkeys.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
50 changes: 40 additions & 10 deletions internal/controller/headscalepreauthkey_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +250 to +261
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expiration check uses time.Now().After(expiresAt) and later calls time.Until(expiresAt) with a different time.Now(). If ExpiresAt is equal/very close to now, this can fall through the "active" path, compute RequeueAfter <= 0, and then never reconcile again—leaving Ready=True after the key has actually expired. Consider capturing now := time.Now() once and treating ExpiresAt <= now as expired (e.g., !expiresAt.After(now)).

Suggested change
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
now := time.Now()
if preAuthKey.Status.ExpiresAt != nil {
expiresAt := preAuthKey.Status.ExpiresAt.Time
if !expiresAt.After(now) {
log.Info("PreAuth key has expired", "keyID", preAuthKey.Status.KeyID, "expiresAt", expiresAt)
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
}

Copilot uses AI. Check for mistakes.
}

// 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)
}
Comment on lines +264 to +268
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RequeueAfter is set to time.Until(ExpiresAt). When ExpiresAt is set, this schedules reconciliation exactly at expiration (not before), and can produce 0/negative durations (which controller-runtime treats as "no requeue"). To reliably transition to KeyExpired, clamp to a small positive minimum and/or requeue slightly before expiration (e.g., ExpiresAt minus a small buffer).

Copilot uses AI. Check for mistakes.
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,
Expand Down Expand Up @@ -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
Comment on lines +490 to +491
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status.ExpiresAt is always set to time.Now().Add(expiration). This diverges from the actual request semantics in pkg/headscale/client.go where expiration is only sent when expiration > 0; for expiration==0 (e.g. "0s"), Headscale will create a non-expiring key but the controller will record ExpiresAt=now and later mark it expired. Also, recomputing time.Now() here can drift from the timestamp actually sent to Headscale. Consider only setting ExpiresAt when expiration > 0 and using the same computed expiration timestamp that was used in the CreatePreAuthKey request (or returning it from hsclient).

Suggested change
expiresAt := metav1.NewTime(time.Now().Add(expiration))
preAuthKey.Status.ExpiresAt = &expiresAt
if expiration > 0 {
expiresAt := metav1.NewTime(time.Now().Add(expiration))
preAuthKey.Status.ExpiresAt = &expiresAt
} else {
preAuthKey.Status.ExpiresAt = nil
}

Copilot uses AI. Check for mistakes.

if err := r.Status().Update(ctx, preAuthKey); err != nil {
return fmt.Errorf("failed to update HeadscalePreAuthKey status: %w", err)
Expand Down
128 changes: 128 additions & 0 deletions internal/controller/headscalepreauthkey_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down