From 73a2ca93776ab80bb3e4a5b87c31e4812a24e491 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 31 Oct 2026 14:25:40 +0000 Subject: [PATCH] api/v2: add POST /device/{id}/expire SaaS has it, and the replays went round its absence. Same effect as the v1 route. --- hscontrol/api/v2/devices.go | 27 ++++++++++++++++++++++ hscontrol/apiv2_oauth_matrix_test.go | 1 + hscontrol/servertest/via_ha_compat_test.go | 14 +++-------- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go index 8e2534705..fa5777a4b 100644 --- a/hscontrol/api/v2/devices.go +++ b/hscontrol/api/v2/devices.go @@ -353,6 +353,33 @@ func registerDevices(api huma.API, b Backend) { return &emptyOutput{}, nil }) + huma.Register(api, requireScope(huma.Operation{ + OperationID: "expireDevice", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/expire", + Summary: "Expire a device's key", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, scope.DevicesCore), func(ctx context.Context, in *deviceByIDInput) (*emptyOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + now := time.Now() + + _, nodeChange, err := b.State.SetNodeExpiry(node.ID(), &now) + b.Change(nodeChange) + + if err != nil { + return nil, mapError("expiring device", err) + } + + return &emptyOutput{}, nil + }) + huma.Register(api, requireScope(huma.Operation{ OperationID: "setDeviceKey", Method: http.MethodPost, diff --git a/hscontrol/apiv2_oauth_matrix_test.go b/hscontrol/apiv2_oauth_matrix_test.go index 83b5197e3..d31a3eb63 100644 --- a/hscontrol/apiv2_oauth_matrix_test.go +++ b/hscontrol/apiv2_oauth_matrix_test.go @@ -117,6 +117,7 @@ func TestAPIv2OAuthMatrix_Enforcement(t *testing.T) { {"listDevices", http.MethodGet, "/api/v2/tailnet/-/devices", scope.DevicesCoreRead, nil, false}, {"deleteDevice", http.MethodDelete, "/api/v2/device/1", scope.DevicesCore, nil, false}, {"authorizeDevice", http.MethodPost, "/api/v2/device/1/authorized", scope.DevicesCore, map[string]any{}, false}, + {"expireDevice", http.MethodPost, "/api/v2/device/1/expire", scope.DevicesCore, map[string]any{}, false}, {"setDeviceName", http.MethodPost, "/api/v2/device/1/name", scope.DevicesCore, map[string]any{}, false}, {"setDeviceTags", http.MethodPost, "/api/v2/device/1/tags", scope.DevicesCore, map[string]any{}, false}, {"setDeviceKey", http.MethodPost, "/api/v2/device/1/key", scope.DevicesCore, map[string]any{}, false}, diff --git a/hscontrol/servertest/via_ha_compat_test.go b/hscontrol/servertest/via_ha_compat_test.go index e51bf0781..184b9473f 100644 --- a/hscontrol/servertest/via_ha_compat_test.go +++ b/hscontrol/servertest/via_ha_compat_test.go @@ -1038,10 +1038,9 @@ func (r *stepReplay) setPolicy(t *testing.T, stepName, policy string) { } } -// api replays one admin exchange, the i-th action of its step. Device and -// key deletes go to the v2 API; key mints and expiry, which the v2 API lacks -// or would answer with secrets, go to State. Reads are replayed only with -// readAPI. +// api replays one admin exchange, the i-th action of its step. Device calls +// and key deletes go to the v2 API; key mints, which it would answer with +// secrets, go to State. Reads are replayed only with readAPI. func (r *stepReplay) api(t *testing.T, stepName string, i int, a testcapture.Action) { t.Helper() @@ -1121,13 +1120,6 @@ func (r *stepReplay) api(t *testing.T, stepName string, i int, a testcapture.Act // Headscale has no user roles; a role decides only what the user's // own API key may call, and the replay mints for him directly. - case a.Method == http.MethodPost && strings.HasSuffix(a.Path, "/expire"): - now := time.Now() - - _, c, err := r.srv.State().SetNodeExpiry(r.node(t, deviceAlias(t, a.Path)).ID(), &now) - require.NoError(t, err, "%s: expire", stepName) - r.srv.App.Change(c) - case strings.HasPrefix(a.Path, "/device/"): path, ok := r.v2Path(a.Path) require.True(t, ok, "%s: %s names no single device", stepName, a.Path)