From ddbd3e14ba6fb26468a7ae4925551b09fda0eda5 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 1 Nov 2025 08:03:37 +0100 Subject: [PATCH 1/3] db: remove all old, unused tables (#2844) --- hscontrol/db/db.go | 20 ++++++++++ ...ump_schema-to-0.27.0-old-table-cleanup.sql | 40 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index e18f2e5d..581ca6d5 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -932,6 +932,26 @@ AND auth_key_id NOT IN ( }, Rollback: func(db *gorm.DB) error { return nil }, }, + { + // Drop all tables that are no longer in use and has existed. + // They potentially still present from broken migrations in the past. + ID: "202510311551", + Migrate: func(tx *gorm.DB) error { + for _, oldTable := range []string{"namespaces", "machines", "shared_machines", "kvs", "pre_auth_key_acl_tags", "routes"} { + err := tx.Migrator().DropTable(oldTable) + if err != nil { + log.Trace().Str("table", oldTable). + Err(err). + Msg("Error dropping old table, continuing...") + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, + }, // From this point, the following rules must be followed: // - NEVER use gorm.AutoMigrate, write the exact migration steps needed // - AutoMigrate depends on the struct staying exactly the same, which it won't over time. diff --git a/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql b/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql new file mode 100644 index 00000000..388fefbc --- /dev/null +++ b/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql @@ -0,0 +1,40 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE `migrations` (`id` text,PRIMARY KEY (`id`)); +INSERT INTO migrations VALUES('202312101416'); +INSERT INTO migrations VALUES('202312101430'); +INSERT INTO migrations VALUES('202402151347'); +INSERT INTO migrations VALUES('2024041121742'); +INSERT INTO migrations VALUES('202406021630'); +INSERT INTO migrations VALUES('202409271400'); +INSERT INTO migrations VALUES('202407191627'); +INSERT INTO migrations VALUES('202408181235'); +INSERT INTO migrations VALUES('202501221827'); +INSERT INTO migrations VALUES('202501311657'); +INSERT INTO migrations VALUES('202502070949'); +INSERT INTO migrations VALUES('202502131714'); +INSERT INTO migrations VALUES('202502171819'); +INSERT INTO migrations VALUES('202505091439'); +INSERT INTO migrations VALUES('202505141324'); +CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`name` text,`display_name` text,`email` text,`provider_identifier` text,`provider` text,`profile_pic_url` text); +CREATE TABLE `pre_auth_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`key` text,`user_id` integer,`reusable` numeric,`ephemeral` numeric DEFAULT false,`used` numeric DEFAULT false,`tags` text,`created_at` datetime,`expiration` datetime,CONSTRAINT `fk_pre_auth_keys_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL); +CREATE TABLE `api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`prefix` text,`hash` blob,`created_at` datetime,`expiration` datetime,`last_seen` datetime); +CREATE TABLE IF NOT EXISTS "nodes" (`id` integer PRIMARY KEY AUTOINCREMENT,`machine_key` text,`node_key` text,`disco_key` text,`endpoints` text,`host_info` text,`ipv4` text,`ipv6` text,`hostname` text,`given_name` varchar(63),`user_id` integer,`register_method` text,`forced_tags` text,`auth_key_id` integer,`expiry` datetime,`last_seen` datetime,`approved_routes` text,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,CONSTRAINT `fk_nodes_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,CONSTRAINT `fk_nodes_auth_key` FOREIGN KEY (`auth_key_id`) REFERENCES `pre_auth_keys`(`id`)); +CREATE TABLE `policies` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`data` text); +DELETE FROM sqlite_sequence; +INSERT INTO sqlite_sequence VALUES('nodes',0); +CREATE INDEX `idx_users_deleted_at` ON `users`(`deleted_at`); +CREATE UNIQUE INDEX `idx_api_keys_prefix` ON `api_keys`(`prefix`); +CREATE INDEX `idx_policies_deleted_at` ON `policies`(`deleted_at`); +CREATE UNIQUE INDEX idx_provider_identifier ON users (provider_identifier) WHERE provider_identifier IS NOT NULL; +CREATE UNIQUE INDEX idx_name_provider_identifier ON users (name,provider_identifier); +CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users (name) WHERE provider_identifier IS NULL; + +-- Create all the old tables we have had and ensure they are clean up. +CREATE TABLE `namespaces` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `machines` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `kvs` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `shared_machines` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `pre_auth_key_acl_tags` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `routes` (`id` text,PRIMARY KEY (`id`)); +COMMIT; From 456a5d5cceea654b8c7c9b4a5db2336b62de8bec Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 1 Nov 2025 08:08:22 +0100 Subject: [PATCH 2/3] db: ignore _litestream tables when validating (#2843) --- flake.nix | 2 +- go.mod | 6 +++--- go.sum | 6 ++++++ hscontrol/db/db.go | 12 +++++++++++- .../sqlite/headscale_0.26.1_schema-litestream.sql | 14 ++++++++++++++ 5 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.26.1_schema-litestream.sql diff --git a/flake.nix b/flake.nix index c064c7fe..f8eb6dd1 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ overlay = _: prev: let pkgs = nixpkgs.legacyPackages.${prev.system}; buildGo = pkgs.buildGo125Module; - vendorHash = "sha256-GUIzlPRsyEq1uSTzRNds9p1uVu4pTeH5PAxrJ5Njhis="; + vendorHash = "sha256-VOi4PGZ8I+2MiwtzxpKc/4smsL5KcH/pHVkjJfAFPJ0="; in { headscale = buildGo { pname = "headscale"; diff --git a/go.mod b/go.mod index b96cedf1..67c6c089 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/tailscale/hujson v0.0.0-20250226034555-ec1d1c113d33 - github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694 + github.com/tailscale/squibble v0.0.0-20251030164342-4d5df9caa993 github.com/tailscale/tailsql v0.0.0-20250421235516-02f85f087b97 github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e go4.org/netipx v0.0.0-20231129151722-fdeea329fbba @@ -115,7 +115,7 @@ require ( github.com/containerd/errdefs v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect - github.com/creachadair/mds v0.25.2 // indirect + github.com/creachadair/mds v0.25.10 // indirect github.com/dblohm7/wingoes v0.0.0-20240123200102-b75a8a7d7eb0 // indirect github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect github.com/distribution/reference v0.6.0 // indirect @@ -159,7 +159,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jsimonetti/rtnetlink v1.4.1 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/lib/pq v1.10.9 // indirect diff --git a/go.sum b/go.sum index 1b09acc5..e78e9aff 100644 --- a/go.sum +++ b/go.sum @@ -126,6 +126,8 @@ github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wz github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8= github.com/creachadair/mds v0.25.2 h1:xc0S0AfDq5GX9KUR5sLvi5XjA61/P6S5e0xFs1vA18Q= github.com/creachadair/mds v0.25.2/go.mod h1:+s4CFteFRj4eq2KcGHW8Wei3u9NyzSPzNV32EvjyK/Q= +github.com/creachadair/mds v0.25.10 h1:9k9JB35D1xhOCFl0liBhagBBp8fWWkKZrA7UXsfoHtA= +github.com/creachadair/mds v0.25.10/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -278,6 +280,8 @@ github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNa github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= @@ -461,6 +465,8 @@ github.com/tailscale/setec v0.0.0-20250305161714-445cadbbca3d h1:mnqtPWYyvNiPU9l github.com/tailscale/setec v0.0.0-20250305161714-445cadbbca3d/go.mod h1:9BzmlFc3OLqLzLTF/5AY+BMs+clxMqyhSGzgXIm8mNI= github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694 h1:95eIP97c88cqAFU/8nURjgI9xxPbD+Ci6mY/a79BI/w= github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694/go.mod h1:veguaG8tVg1H/JG5RfpoUW41I+O8ClPElo/fTYr8mMk= +github.com/tailscale/squibble v0.0.0-20251030164342-4d5df9caa993 h1:FyiiAvDAxpB0DrW2GW3KOVfi3YFOtsQUEeFWbf55JJU= +github.com/tailscale/squibble v0.0.0-20251030164342-4d5df9caa993/go.mod h1:xJkMmR3t+thnUQhA3Q4m2VSlS5pcOq+CIjmU/xfKKx4= github.com/tailscale/tailsql v0.0.0-20250421235516-02f85f087b97 h1:JJkDnrAhHvOCttk8z9xeZzcDlzzkRA7+Duxj9cwOyxk= github.com/tailscale/tailsql v0.0.0-20250421235516-02f85f087b97/go.mod h1:9jS8HxwsP2fU4ESZ7DZL+fpH/U66EVlVMzdgznH12RM= github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index 581ca6d5..04c6cc0a 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -982,7 +982,17 @@ AND auth_key_id NOT IN ( ctx, cancel := context.WithTimeout(context.Background(), contextTimeoutSecs*time.Second) defer cancel() - if err := squibble.Validate(ctx, sqlConn, dbSchema); err != nil { + opts := squibble.DigestOptions{ + IgnoreTables: []string{ + // Litestream tables, these are inserted by + // litestream and not part of our schema + // https://litestream.io/how-it-works + "_litestream_lock", + "_litestream_seq", + }, + } + + if err := squibble.Validate(ctx, sqlConn, dbSchema, &opts); err != nil { return nil, fmt.Errorf("validating schema: %w", err) } } diff --git a/hscontrol/db/testdata/sqlite/headscale_0.26.1_schema-litestream.sql b/hscontrol/db/testdata/sqlite/headscale_0.26.1_schema-litestream.sql new file mode 100644 index 00000000..3fc2b319 --- /dev/null +++ b/hscontrol/db/testdata/sqlite/headscale_0.26.1_schema-litestream.sql @@ -0,0 +1,14 @@ +CREATE TABLE `migrations` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`name` text,`display_name` text,`email` text,`provider_identifier` text,`provider` text,`profile_pic_url` text); +CREATE INDEX `idx_users_deleted_at` ON `users`(`deleted_at`); +CREATE TABLE `pre_auth_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`key` text,`user_id` integer,`reusable` numeric,`ephemeral` numeric DEFAULT false,`used` numeric DEFAULT false,`tags` text,`created_at` datetime,`expiration` datetime,CONSTRAINT `fk_pre_auth_keys_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL); +CREATE TABLE `api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`prefix` text,`hash` blob,`created_at` datetime,`expiration` datetime,`last_seen` datetime); +CREATE UNIQUE INDEX `idx_api_keys_prefix` ON `api_keys`(`prefix`); +CREATE TABLE IF NOT EXISTS "nodes" (`id` integer PRIMARY KEY AUTOINCREMENT,`machine_key` text,`node_key` text,`disco_key` text,`endpoints` text,`host_info` text,`ipv4` text,`ipv6` text,`hostname` text,`given_name` varchar(63),`user_id` integer,`register_method` text,`forced_tags` text,`auth_key_id` integer,`expiry` datetime,`last_seen` datetime,`approved_routes` text,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,CONSTRAINT `fk_nodes_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,CONSTRAINT `fk_nodes_auth_key` FOREIGN KEY (`auth_key_id`) REFERENCES `pre_auth_keys`(`id`)); +CREATE TABLE `policies` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`data` text); +CREATE INDEX `idx_policies_deleted_at` ON `policies`(`deleted_at`); +CREATE UNIQUE INDEX idx_provider_identifier ON users (provider_identifier) WHERE provider_identifier IS NOT NULL; +CREATE UNIQUE INDEX idx_name_provider_identifier ON users (name,provider_identifier); +CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users (name) WHERE provider_identifier IS NULL; +CREATE TABLE _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER); +CREATE TABLE _litestream_lock (id INTEGER); 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 3/3] 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; }