mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-30 05:56:40 +09:00
db: backfill zero-time node expiry to NULL
Versions before 0.28 persisted a pointer to a zero time.Time as '0001-01-01 00:00:00+00:00' in nodes.expiry rather than NULL. 0.29 reports those rows as expired. #3197 and #3199 stopped new writes; this normalises the existing rows so the column once again means "no expiry" when unset. Fixes: #3284 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Kristoffer Dalby
parent
77ba225cdb
commit
ea8fc72570
+1
-1
@@ -306,8 +306,8 @@ connected" routers that maintain their control session but cannot route packets.
|
||||
- Remove old migrations for the debian package [#3185](https://github.com/juanfont/headscale/pull/3185)
|
||||
- Install `config-example.yaml` as example for the debian package [#3186](https://github.com/juanfont/headscale/pull/3186)
|
||||
- Fix user-owned re-registration with zero client expiry and no default storing `0001-01-01 00:00:00` in the database instead of `NULL` [#3199](https://github.com/juanfont/headscale/pull/3199)
|
||||
- Pre-existing rows with `0001-01-01 00:00:00` are not backfilled; they clear themselves the next time the node re-registers
|
||||
- Fix `tailscaled` restart on a node with no expiry resetting `NULL` to `0001-01-01 00:00:00` in the database, affecting both tagged and untagged nodes [#3197](https://github.com/juanfont/headscale/pull/3197)
|
||||
- Backfill `nodes.expiry` rows persisted by older versions as `0001-01-01 00:00:00` to `NULL`, so nodes upgraded from <0.28 stop reporting as expired [#3284](https://github.com/juanfont/headscale/issues/3284)
|
||||
|
||||
## 0.28.0 (2026-02-04)
|
||||
|
||||
|
||||
@@ -722,6 +722,28 @@ WHERE tags IS NOT NULL AND tags != '[]' AND tags != '';
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
{
|
||||
// Clear zero-time node expiry values to NULL.
|
||||
// Versions before 0.28 persisted a pointer to a zero
|
||||
// time.Time as '0001-01-01 00:00:00+00:00' rather than
|
||||
// NULL, which 0.29 reports as an expired node. This
|
||||
// normalises the existing rows so the column once
|
||||
// again means "no expiry" when unset.
|
||||
ID: "202605221435-clear-zero-time-node-expiry",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
err := tx.Exec(`
|
||||
UPDATE nodes
|
||||
SET expiry = NULL
|
||||
WHERE expiry IS NOT NULL AND expiry < '1900-01-01';
|
||||
`).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("clearing zero-time node expiry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -144,6 +144,60 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) {
|
||||
assert.NotContains(t, node7.Tags, "tag:forbidden", "node7 should NOT have tag:forbidden (unauthorized)")
|
||||
},
|
||||
},
|
||||
// Test for the zero-time node expiry migration
|
||||
// (202605221435-clear-zero-time-node-expiry). Pre-0.28 versions
|
||||
// stored a zero time.Time as '0001-01-01 00:00:00+00:00' rather
|
||||
// than NULL, which caused 0.29 to report those nodes as expired.
|
||||
// Fixes: https://github.com/juanfont/headscale/issues/3284
|
||||
{
|
||||
dbPath: "testdata/sqlite/zero_time_expiry_migration_test.sql",
|
||||
wantFunc: func(t *testing.T, hsdb *HSDatabase) {
|
||||
t.Helper()
|
||||
|
||||
nodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) {
|
||||
return ListNodes(rx)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, nodes, 5, "should have all 5 nodes")
|
||||
|
||||
byHostname := make(map[string]*types.Node, len(nodes))
|
||||
for _, n := range nodes {
|
||||
byHostname[n.Hostname] = n
|
||||
}
|
||||
|
||||
// Node 1 had a zero-time expiry; should be cleared.
|
||||
node1 := byHostname["node1"]
|
||||
require.NotNil(t, node1, "node1 should exist")
|
||||
assert.Nil(t, node1.Expiry, "node1 zero-time expiry should be cleared to NULL")
|
||||
assert.False(t, node1.IsExpired(), "node1 should not be reported as expired")
|
||||
|
||||
// Node 2 already had NULL expiry; should still be NULL.
|
||||
node2 := byHostname["node2"]
|
||||
require.NotNil(t, node2, "node2 should exist")
|
||||
assert.Nil(t, node2.Expiry, "node2 NULL expiry should be preserved")
|
||||
assert.False(t, node2.IsExpired(), "node2 should not be reported as expired")
|
||||
|
||||
// Node 3 had a real future expiry; should be preserved.
|
||||
node3 := byHostname["node3"]
|
||||
require.NotNil(t, node3, "node3 should exist")
|
||||
require.NotNil(t, node3.Expiry, "node3 future expiry should be preserved")
|
||||
assert.Equal(t, 2099, node3.Expiry.UTC().Year(), "node3 expiry year should be 2099")
|
||||
assert.False(t, node3.IsExpired(), "node3 with future expiry should not be expired")
|
||||
|
||||
// Node 4 had a real past expiry; should be preserved.
|
||||
node4 := byHostname["node4"]
|
||||
require.NotNil(t, node4, "node4 should exist")
|
||||
require.NotNil(t, node4.Expiry, "node4 past expiry should be preserved")
|
||||
assert.Equal(t, 2020, node4.Expiry.UTC().Year(), "node4 expiry year should be 2020")
|
||||
assert.True(t, node4.IsExpired(), "node4 with past expiry should still be expired")
|
||||
|
||||
// Node 5 also had a zero-time expiry; should be cleared.
|
||||
node5 := byHostname["node5"]
|
||||
require.NotNil(t, node5, "node5 should exist")
|
||||
assert.Nil(t, node5.Expiry, "node5 zero-time expiry should be cleared to NULL")
|
||||
assert.False(t, node5.IsExpired(), "node5 should not be reported as expired")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
-- Test SQL dump for zero-time node expiry migration
|
||||
-- (202605221435-clear-zero-time-node-expiry)
|
||||
--
|
||||
-- Pre-0.28 versions of Headscale persisted a zero time.Time as the string
|
||||
-- '0001-01-01 00:00:00+00:00' in nodes.expiry instead of NULL. Upgrading
|
||||
-- to 0.29 surfaces those rows as "expired" because they look like a
|
||||
-- timestamp at year 1. This dump exercises the data fix.
|
||||
-- Fixes: https://github.com/juanfont/headscale/issues/3284
|
||||
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- Migrations table: all entries BEFORE the zero-time fix have been applied.
|
||||
-- The new migration is intentionally absent so it runs against this dump.
|
||||
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');
|
||||
INSERT INTO migrations VALUES('202507021200');
|
||||
INSERT INTO migrations VALUES('202510311551');
|
||||
INSERT INTO migrations VALUES('202511101554-drop-old-idx');
|
||||
INSERT INTO migrations VALUES('202511011637-preauthkey-bcrypt');
|
||||
INSERT INTO migrations VALUES('202511122344-remove-newline-index');
|
||||
INSERT INTO migrations VALUES('202511131445-node-forced-tags-to-tags');
|
||||
INSERT INTO migrations VALUES('202601121700-migrate-hostinfo-request-tags');
|
||||
INSERT INTO migrations VALUES('202602201200-clear-tagged-node-user-id');
|
||||
|
||||
-- Users table
|
||||
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);
|
||||
INSERT INTO users VALUES(1,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'user1','User One','user1@example.com',NULL,NULL,NULL);
|
||||
|
||||
-- Pre-auth keys table
|
||||
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,`prefix` text,`hash` blob,CONSTRAINT `fk_pre_auth_keys_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL);
|
||||
|
||||
-- API keys table
|
||||
CREATE TABLE `api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`prefix` text,`hash` blob,`created_at` datetime,`expiration` datetime,`last_seen` datetime);
|
||||
|
||||
-- Nodes table - current schema (after the tags rename + last_seen/expiry reordering)
|
||||
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,`tags` text,`auth_key_id` integer,`last_seen` datetime,`expiry` 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`));
|
||||
|
||||
-- Node 1: zero-time expiry. After migration: expiry IS NULL.
|
||||
INSERT INTO nodes VALUES(1,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e01','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605501','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57701','[]','{}','100.64.0.1','fd7a:115c:a1e0::1','node1','node1',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00','0001-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
|
||||
|
||||
-- Node 2: NULL expiry already. After migration: still NULL.
|
||||
INSERT INTO nodes VALUES(2,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e02','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605502','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57702','[]','{}','100.64.0.2','fd7a:115c:a1e0::2','node2','node2',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00',NULL,'[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
|
||||
|
||||
-- Node 3: real future expiry. After migration: preserved.
|
||||
INSERT INTO nodes VALUES(3,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e03','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605503','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57703','[]','{}','100.64.0.3','fd7a:115c:a1e0::3','node3','node3',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00','2099-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
|
||||
|
||||
-- Node 4: real past expiry (legitimately expired). After migration: preserved.
|
||||
INSERT INTO nodes VALUES(4,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e04','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605504','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57704','[]','{}','100.64.0.4','fd7a:115c:a1e0::4','node4','node4',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00','2020-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
|
||||
|
||||
-- Node 5: another zero-time row to confirm the WHERE clause matches multiple rows.
|
||||
INSERT INTO nodes VALUES(5,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e05','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605505','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57705','[]','{}','100.64.0.5','fd7a:115c:a1e0::5','node5','node5',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00','0001-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
|
||||
|
||||
-- Policies table (empty)
|
||||
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('users',1);
|
||||
INSERT INTO sqlite_sequence VALUES('nodes',5);
|
||||
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 UNIQUE INDEX IF NOT EXISTS idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != '';
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user