From f9bb88ad24d95c2dc35fae6e433e1e8eb8faa926 Mon Sep 17 00:00:00 2001 From: Andrey <3942342+bobelev@users.noreply.github.com> Date: Sat, 1 Nov 2025 09:09:13 +0200 Subject: [PATCH] expire nodes with a custom timestamp (#2828) --- .github/workflows/test-integration.yaml | 1 + CHANGELOG.md | 5 + cmd/headscale/cli/nodes.go | 27 +++++ gen/go/headscale/v1/apikey.pb.go | 2 +- gen/go/headscale/v1/device.pb.go | 2 +- gen/go/headscale/v1/headscale.pb.go | 2 +- gen/go/headscale/v1/headscale.pb.gw.go | 14 +++ gen/go/headscale/v1/node.pb.go | 36 ++++--- gen/go/headscale/v1/policy.pb.go | 2 +- gen/go/headscale/v1/preauthkey.pb.go | 2 +- gen/go/headscale/v1/user.pb.go | 2 +- .../headscale/v1/headscale.swagger.json | 7 ++ hscontrol/db/node.go | 4 +- hscontrol/grpcv1.go | 7 +- integration/general_test.go | 98 +++++++++++++++++++ proto/headscale/v1/node.proto | 5 +- 16 files changed, 191 insertions(+), 25 deletions(-) diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 37aa792e..735c50bf 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -70,6 +70,7 @@ jobs: - TestTaildrop - TestUpdateHostnameFromClient - TestExpireNode + - TestSetNodeExpiryInFuture - TestNodeOnlineStatus - TestPingAllByIPManyUpDown - Test2118DeletingOnlineNodePanics diff --git a/CHANGELOG.md b/CHANGELOG.md index da547451..02986867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Next +### Changes + +- Expire nodes with a custom timestamp + [#2828](https://github.com/juanfont/headscale/pull/2828) + ## 0.27.0 (2025-10-27) **Minimum supported Tailscale client version: v1.64.0** diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index e1b8e7b3..e1b040f0 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -15,6 +15,7 @@ import ( "github.com/samber/lo" "github.com/spf13/cobra" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" "tailscale.com/types/key" ) @@ -51,6 +52,7 @@ func init() { nodeCmd.AddCommand(registerNodeCmd) expireNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)") + expireNodeCmd.Flags().StringP("expiry", "e", "", "Set expire to (RFC3339 format, e.g. 2025-08-27T10:00:00Z), or leave empty to expire immediately.") err = expireNodeCmd.MarkFlagRequired("identifier") if err != nil { log.Fatal(err.Error()) @@ -289,12 +291,37 @@ var expireNodeCmd = &cobra.Command{ ) } + expiry, err := cmd.Flags().GetString("expiry") + if err != nil { + ErrorOutput( + err, + fmt.Sprintf("Error converting expiry to string: %s", err), + output, + ) + + return + } + expiryTime := time.Now() + if expiry != "" { + expiryTime, err = time.Parse(time.RFC3339, expiry) + if err != nil { + ErrorOutput( + err, + fmt.Sprintf("Error converting expiry to string: %s", err), + output, + ) + + return + } + } + ctx, client, conn, cancel := newHeadscaleCLIWithConfig() defer cancel() defer conn.Close() request := &v1.ExpireNodeRequest{ NodeId: identifier, + Expiry: timestamppb.New(expiryTime), } response, err := client.ExpireNode(ctx, request) diff --git a/gen/go/headscale/v1/apikey.pb.go b/gen/go/headscale/v1/apikey.pb.go index 38aaf55a..a9f6a7b8 100644 --- a/gen/go/headscale/v1/apikey.pb.go +++ b/gen/go/headscale/v1/apikey.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/apikey.proto diff --git a/gen/go/headscale/v1/device.pb.go b/gen/go/headscale/v1/device.pb.go index c31bd754..8b150f96 100644 --- a/gen/go/headscale/v1/device.pb.go +++ b/gen/go/headscale/v1/device.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/device.proto diff --git a/gen/go/headscale/v1/headscale.pb.go b/gen/go/headscale/v1/headscale.pb.go index e9fdfd7f..2c594f5a 100644 --- a/gen/go/headscale/v1/headscale.pb.go +++ b/gen/go/headscale/v1/headscale.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/headscale.proto diff --git a/gen/go/headscale/v1/headscale.pb.gw.go b/gen/go/headscale/v1/headscale.pb.gw.go index fcd7fa2b..2a8ac365 100644 --- a/gen/go/headscale/v1/headscale.pb.gw.go +++ b/gen/go/headscale/v1/headscale.pb.gw.go @@ -471,6 +471,8 @@ func local_request_HeadscaleService_DeleteNode_0(ctx context.Context, marshaler return msg, metadata, err } +var filter_HeadscaleService_ExpireNode_0 = &utilities.DoubleArray{Encoding: map[string]int{"node_id": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}} + func request_HeadscaleService_ExpireNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var ( protoReq ExpireNodeRequest @@ -485,6 +487,12 @@ func request_HeadscaleService_ExpireNode_0(ctx context.Context, marshaler runtim if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ExpireNode_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } msg, err := client.ExpireNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err } @@ -503,6 +511,12 @@ func local_request_HeadscaleService_ExpireNode_0(ctx context.Context, marshaler if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ExpireNode_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } msg, err := server.ExpireNode(ctx, &protoReq) return msg, metadata, err } diff --git a/gen/go/headscale/v1/node.pb.go b/gen/go/headscale/v1/node.pb.go index 60d8fb95..f04c7e2d 100644 --- a/gen/go/headscale/v1/node.pb.go +++ b/gen/go/headscale/v1/node.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/node.proto @@ -729,6 +729,7 @@ func (*DeleteNodeResponse) Descriptor() ([]byte, []int) { type ExpireNodeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` + Expiry *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expiry,proto3" json:"expiry,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -770,6 +771,13 @@ func (x *ExpireNodeRequest) GetNodeId() uint64 { return 0 } +func (x *ExpireNodeRequest) GetExpiry() *timestamppb.Timestamp { + if x != nil { + return x.Expiry + } + return nil +} + type ExpireNodeResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` @@ -1349,9 +1357,10 @@ const file_headscale_v1_node_proto_rawDesc = "" + "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\",\n" + "\x11DeleteNodeRequest\x12\x17\n" + "\anode_id\x18\x01 \x01(\x04R\x06nodeId\"\x14\n" + - "\x12DeleteNodeResponse\",\n" + + "\x12DeleteNodeResponse\"`\n" + "\x11ExpireNodeRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\"<\n" + + "\anode_id\x18\x01 \x01(\x04R\x06nodeId\x122\n" + + "\x06expiry\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x06expiry\"<\n" + "\x12ExpireNodeResponse\x12&\n" + "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"G\n" + "\x11RenameNodeRequest\x12\x17\n" + @@ -1439,16 +1448,17 @@ var file_headscale_v1_node_proto_depIdxs = []int32{ 1, // 7: headscale.v1.GetNodeResponse.node:type_name -> headscale.v1.Node 1, // 8: headscale.v1.SetTagsResponse.node:type_name -> headscale.v1.Node 1, // 9: headscale.v1.SetApprovedRoutesResponse.node:type_name -> headscale.v1.Node - 1, // 10: headscale.v1.ExpireNodeResponse.node:type_name -> headscale.v1.Node - 1, // 11: headscale.v1.RenameNodeResponse.node:type_name -> headscale.v1.Node - 1, // 12: headscale.v1.ListNodesResponse.nodes:type_name -> headscale.v1.Node - 1, // 13: headscale.v1.MoveNodeResponse.node:type_name -> headscale.v1.Node - 1, // 14: headscale.v1.DebugCreateNodeResponse.node:type_name -> headscale.v1.Node - 15, // [15:15] is the sub-list for method output_type - 15, // [15:15] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 25, // 10: headscale.v1.ExpireNodeRequest.expiry:type_name -> google.protobuf.Timestamp + 1, // 11: headscale.v1.ExpireNodeResponse.node:type_name -> headscale.v1.Node + 1, // 12: headscale.v1.RenameNodeResponse.node:type_name -> headscale.v1.Node + 1, // 13: headscale.v1.ListNodesResponse.nodes:type_name -> headscale.v1.Node + 1, // 14: headscale.v1.MoveNodeResponse.node:type_name -> headscale.v1.Node + 1, // 15: headscale.v1.DebugCreateNodeResponse.node:type_name -> headscale.v1.Node + 16, // [16:16] is the sub-list for method output_type + 16, // [16:16] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_headscale_v1_node_proto_init() } diff --git a/gen/go/headscale/v1/policy.pb.go b/gen/go/headscale/v1/policy.pb.go index 4ac6e3b2..fefcfb22 100644 --- a/gen/go/headscale/v1/policy.pb.go +++ b/gen/go/headscale/v1/policy.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/policy.proto diff --git a/gen/go/headscale/v1/preauthkey.pb.go b/gen/go/headscale/v1/preauthkey.pb.go index de7f3248..661f170d 100644 --- a/gen/go/headscale/v1/preauthkey.pb.go +++ b/gen/go/headscale/v1/preauthkey.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/preauthkey.proto diff --git a/gen/go/headscale/v1/user.pb.go b/gen/go/headscale/v1/user.pb.go index 97fcaff9..fa6d49bb 100644 --- a/gen/go/headscale/v1/user.pb.go +++ b/gen/go/headscale/v1/user.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/user.proto diff --git a/gen/openapiv2/headscale/v1/headscale.swagger.json b/gen/openapiv2/headscale/v1/headscale.swagger.json index 2900d65f..6a7b48ad 100644 --- a/gen/openapiv2/headscale/v1/headscale.swagger.json +++ b/gen/openapiv2/headscale/v1/headscale.swagger.json @@ -406,6 +406,13 @@ "required": true, "type": "string", "format": "uint64" + }, + { + "name": "expiry", + "in": "query", + "required": false, + "type": "string", + "format": "date-time" } ], "tags": [ diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go index 5493a55c..4e4533be 100644 --- a/hscontrol/db/node.go +++ b/hscontrol/db/node.go @@ -27,9 +27,7 @@ const ( NodeGivenNameTrimSize = 2 ) -var ( - invalidDNSRegex = regexp.MustCompile("[^a-z0-9-.]+") -) +var invalidDNSRegex = regexp.MustCompile("[^a-z0-9-.]+") var ( ErrNodeNotFound = errors.New("node not found") diff --git a/hscontrol/grpcv1.go b/hscontrol/grpcv1.go index 1d620ba6..6d5189b8 100644 --- a/hscontrol/grpcv1.go +++ b/hscontrol/grpcv1.go @@ -416,9 +416,12 @@ func (api headscaleV1APIServer) ExpireNode( ctx context.Context, request *v1.ExpireNodeRequest, ) (*v1.ExpireNodeResponse, error) { - now := time.Now() + expiry := time.Now() + if request.GetExpiry() != nil { + expiry = request.GetExpiry().AsTime() + } - node, nodeChange, err := api.h.state.SetNodeExpiry(types.NodeID(request.GetNodeId()), now) + node, nodeChange, err := api.h.state.SetNodeExpiry(types.NodeID(request.GetNodeId()), expiry) if err != nil { return nil, err } diff --git a/integration/general_test.go b/integration/general_test.go index 2432db9c..c68768f7 100644 --- a/integration/general_test.go +++ b/integration/general_test.go @@ -819,6 +819,104 @@ func TestExpireNode(t *testing.T) { } } +// TestSetNodeExpiryInFuture tests setting arbitrary expiration date +// New expiration date should be stored in the db and propagated to all peers +func TestSetNodeExpiryInFuture(t *testing.T) { + IntegrationSkip(t) + + spec := ScenarioSpec{ + NodesPerUser: len(MustTestVersions), + Users: []string{"user1"}, + } + + scenario, err := NewScenario(spec) + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("expirenodefuture")) + requireNoErrHeadscaleEnv(t, err) + + allClients, err := scenario.ListTailscaleClients() + requireNoErrListClients(t, err) + + err = scenario.WaitForTailscaleSync() + requireNoErrSync(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + targetExpiry := time.Now().Add(2 * time.Hour).Round(time.Second).UTC() + + result, err := headscale.Execute( + []string{ + "headscale", "nodes", "expire", + "--identifier", "1", + "--output", "json", + "--expiry", targetExpiry.Format(time.RFC3339), + }, + ) + require.NoError(t, err) + + var node v1.Node + err = json.Unmarshal([]byte(result), &node) + require.NoError(t, err) + + require.True(t, node.GetExpiry().AsTime().After(time.Now())) + require.WithinDuration(t, targetExpiry, node.GetExpiry().AsTime(), 2*time.Second) + + var nodeKey key.NodePublic + err = nodeKey.UnmarshalText([]byte(node.GetNodeKey())) + require.NoError(t, err) + + for _, client := range allClients { + if client.Hostname() == node.GetName() { + continue + } + + assert.EventuallyWithT( + t, func(ct *assert.CollectT) { + status, err := client.Status() + assert.NoError(ct, err) + + peerStatus, ok := status.Peer[nodeKey] + assert.True(ct, ok, "node key should be present in peer list") + + if !ok { + return + } + + assert.NotNil(ct, peerStatus.KeyExpiry) + assert.NotNil(ct, peerStatus.Expired) + + if peerStatus.KeyExpiry != nil { + assert.WithinDuration( + ct, + targetExpiry, + *peerStatus.KeyExpiry, + 5*time.Second, + "node %q should have key expiry near the requested future time", + peerStatus.HostName, + ) + + assert.Truef( + ct, + peerStatus.KeyExpiry.After(time.Now()), + "node %q should have a key expiry timestamp in the future", + peerStatus.HostName, + ) + } + + assert.Falsef( + ct, + peerStatus.Expired, + "node %q should not be marked as expired", + peerStatus.HostName, + ) + }, 3*time.Minute, 5*time.Second, "Waiting for future expiry to propagate", + ) + } +} + func TestNodeOnlineStatus(t *testing.T) { IntegrationSkip(t) diff --git a/proto/headscale/v1/node.proto b/proto/headscale/v1/node.proto index 89d2c347..fb074008 100644 --- a/proto/headscale/v1/node.proto +++ b/proto/headscale/v1/node.proto @@ -82,7 +82,10 @@ message DeleteNodeRequest { uint64 node_id = 1; } message DeleteNodeResponse {} -message ExpireNodeRequest { uint64 node_id = 1; } +message ExpireNodeRequest { + uint64 node_id = 1; + google.protobuf.Timestamp expiry = 2; +} message ExpireNodeResponse { Node node = 1; }