diff --git a/AGENTS.md b/AGENTS.md index 07db0190..c8f48115 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,19 +185,17 @@ headscale/ ## Database Migration Rules These rules are load-bearing — violating them corrupts production -databases. The `migrationsRequiringFKDisabled` map in -`hscontrol/db/db.go` is frozen (see the comment above it). All new +databases. Migrations start at 0.29.0; `checkMinimumMigration` in +`hscontrol/db/versioncheck.go` refuses older databases. All new migrations must: 1. **Never reorder existing migrations.** Migration order is immutable once committed. 2. **Only add new migrations to the end** of the migrations array. -3. **Never disable foreign keys.** No new entries in - `migrationsRequiringFKDisabled`. +3. **Never disable foreign keys.** 4. **Use the migration ID format** `YYYYMMDDHHMM-short-description` - (timestamp + descriptive suffix). Example: `202602201200-clear-tagged-node-user-id`. -5. **Never rename columns** that later migrations reference. Let - `AutoMigrate` create a new column if needed. + (timestamp + descriptive suffix). Example: `202607241200-clear-tagged-node-expiry`. +5. **Never use `AutoMigrate`** in a migration; write explicit DDL. ## Tags-as-Identity diff --git a/CHANGELOG.md b/CHANGELOG.md index f7aa4488..52d3623f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,10 @@ tags; any other tag is rejected, for new and re-registering nodes alike. See ### BREAKING +#### Database + +- Only upgrades from 0.29.x are supported; migrations for older releases are removed and headscale refuses to start on an older database. Upgrade to the latest 0.29.x first [#3352](https://github.com/juanfont/headscale/pull/3352) + #### API - The gRPC API is removed; all programmatic access now goes through the HTTP API at `/api/v1` [#3324](https://github.com/juanfont/headscale/pull/3324) diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index a2c76faf..96da55b3 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -3,19 +3,15 @@ package db import ( "context" _ "embed" - "encoding/json" "errors" "fmt" - "net/netip" "path/filepath" - "slices" "strconv" "time" "github.com/glebarez/sqlite" "github.com/go-gormigrate/gormigrate/v2" "github.com/juanfont/headscale/hscontrol/db/sqliteconfig" - "github.com/juanfont/headscale/hscontrol/policy" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" "github.com/rs/zerolog/log" @@ -49,15 +45,19 @@ type HSDatabase struct { } // NewHeadscaleDatabase creates a new database connection and runs migrations. -// It accepts the full configuration to allow migrations access to policy settings. // -//nolint:gocyclo // complex database initialization with many migrations +//nolint:gocyclo // migration closures inflate the count; each is linear func NewHeadscaleDatabase(cfg *types.Config) (*HSDatabase, error) { dbConn, err := openDB(cfg.Database) if err != nil { return nil, err } + err = checkMinimumMigration(dbConn) + if err != nil { + return nil, fmt.Errorf("version check: %w", err) + } + err = checkVersionUpgradePath(dbConn) if err != nil { return nil, fmt.Errorf("version check: %w", err) @@ -68,699 +68,24 @@ func NewHeadscaleDatabase(cfg *types.Config) (*HSDatabase, error) { gormigrate.DefaultOptions, []*gormigrate.Migration{ // New migrations must be added as transactions at the end of this list. - // Migrations start from v0.25.0. If upgrading from v0.24.x or earlier, - // you must first upgrade to v0.25.1 before upgrading to this version. - - // v0.25.0 - { - // Add a constraint to routes ensuring they cannot exist without a node. - ID: "202501221827", - Migrate: func(tx *gorm.DB) error { - // Remove any invalid routes associated with a node that does not exist. - if tx.Migrator().HasTable(&types.Route{}) && tx.Migrator().HasTable(&types.Node{}) { //nolint:staticcheck // SA1019: Route kept for migrations - err := tx.Exec("delete from routes where node_id not in (select id from nodes)").Error - if err != nil { - return err - } - } - - // Remove any invalid routes without a node_id. - if tx.Migrator().HasTable(&types.Route{}) { //nolint:staticcheck // SA1019: Route kept for migrations - err := tx.Exec("delete from routes where node_id is null").Error - if err != nil { - return err - } - } - - err := tx.AutoMigrate(&types.Route{}) //nolint:staticcheck // SA1019: Route kept for migrations - if err != nil { - return fmt.Errorf("automigrating types.Route: %w", err) - } - - return nil - }, - Rollback: func(db *gorm.DB) error { return nil }, - }, - // Add back constraint so you cannot delete preauth keys that - // is still used by a node. - { - ID: "202501311657", - Migrate: func(tx *gorm.DB) error { - err := tx.AutoMigrate(&types.PreAuthKey{}) - if err != nil { - return fmt.Errorf("automigrating types.PreAuthKey: %w", err) - } - - err = tx.AutoMigrate(&types.Node{}) - if err != nil { - return fmt.Errorf("automigrating types.Node: %w", err) - } - - return nil - }, - Rollback: func(db *gorm.DB) error { return nil }, - }, - // Ensure there are no nodes referring to a deleted preauthkey. - { - ID: "202502070949", - Migrate: func(tx *gorm.DB) error { - if tx.Migrator().HasTable(&types.PreAuthKey{}) { - err := tx.Exec(` -UPDATE nodes -SET auth_key_id = NULL -WHERE auth_key_id IS NOT NULL -AND auth_key_id NOT IN ( - SELECT id FROM pre_auth_keys -); - `).Error - if err != nil { - return fmt.Errorf("setting auth_key to null on nodes with non-existing keys: %w", err) - } - } - - return nil - }, - Rollback: func(db *gorm.DB) error { return nil }, - }, - // v0.26.0 - // Migrate all routes from the Route table to the new field ApprovedRoutes - // in the Node table. Then drop the Route table. - { - ID: "202502131714", - Migrate: func(tx *gorm.DB) error { - if !tx.Migrator().HasColumn(&types.Node{}, "approved_routes") { - err := tx.Migrator().AddColumn(&types.Node{}, "approved_routes") - if err != nil { - return fmt.Errorf("adding column types.Node: %w", err) - } - } - - nodeRoutes := map[uint64][]netip.Prefix{} - - var routes []types.Route //nolint:staticcheck // SA1019: Route kept for migrations - - err = tx.Find(&routes).Error - if err != nil { - return fmt.Errorf("fetching routes: %w", err) - } - - for _, route := range routes { - if route.Enabled { - nodeRoutes[route.NodeID] = append(nodeRoutes[route.NodeID], route.Prefix) - } - } - - for nodeID, routes := range nodeRoutes { - slices.SortFunc(routes, netip.Prefix.Compare) - routes = slices.Compact(routes) - - data, _ := json.Marshal(routes) - - err = tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("approved_routes", data).Error - if err != nil { - return fmt.Errorf("saving approved routes to new column: %w", err) - } - } - - // Drop the old table. - _ = tx.Migrator().DropTable(&types.Route{}) //nolint:staticcheck // SA1019: Route kept for migrations - - return nil - }, - Rollback: func(db *gorm.DB) error { return nil }, - }, - { - ID: "202502171819", - Migrate: func(tx *gorm.DB) error { - // This migration originally removed the last_seen column - // from the node table, but it was added back in - // 202505091439. - return nil - }, - Rollback: func(db *gorm.DB) error { return nil }, - }, - // Add back last_seen column to node table. - { - ID: "202505091439", - Migrate: func(tx *gorm.DB) error { - // Add back last_seen column to node table if it does not exist. - // This is a workaround for the fact that the last_seen column - // was removed in the 202502171819 migration, but only for some - // beta testers. - if !tx.Migrator().HasColumn(&types.Node{}, "last_seen") { - _ = tx.Migrator().AddColumn(&types.Node{}, "last_seen") - } - - return nil - }, - Rollback: func(db *gorm.DB) error { return nil }, - }, - // Fix the provider identifier for users that have a double slash in the - // provider identifier. - { - ID: "202505141324", - Migrate: func(tx *gorm.DB) error { - users, err := ListUsers(tx, nil) - if err != nil { - return fmt.Errorf("listing users: %w", err) - } - - for _, user := range users { - user.ProviderIdentifier.String = types.CleanIdentifier(user.ProviderIdentifier.String) - - err := tx.Save(user).Error - if err != nil { - return fmt.Errorf("saving user: %w", err) - } - } - - return nil - }, - Rollback: func(db *gorm.DB) error { return nil }, - }, - // v0.27.0 - // Schema migration to ensure all tables match the expected schema. - // This migration recreates all tables to match the exact structure in schema.sql, - // preserving all data during the process. - // Only SQLite will be migrated for consistency. - { - ID: "202507021200", - Migrate: func(tx *gorm.DB) error { - // Only run on SQLite - if cfg.Database.Type != types.DatabaseSqlite { - log.Info().Msg("skipping schema migration on non-SQLite database") - return nil - } - - log.Info().Msg("starting schema recreation with table renaming") - - // Rename existing tables to _old versions - tablesToRename := []string{"users", "pre_auth_keys", "api_keys", "nodes", "policies"} - - // Check if routes table exists and drop it (should have been migrated already) - var routesExists bool - - err := tx.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='routes'").Row().Scan(&routesExists) - if err == nil && routesExists { - log.Info().Msg("dropping leftover routes table") - - err := tx.Exec("DROP TABLE routes").Error - if err != nil { - return fmt.Errorf("dropping routes table: %w", err) - } - } - - // Drop all indexes first to avoid conflicts - indexesToDrop := []string{ - "idx_users_deleted_at", - "idx_provider_identifier", - "idx_name_provider_identifier", - "idx_name_no_provider_identifier", - "idx_api_keys_prefix", - "idx_policies_deleted_at", - } - - for _, index := range indexesToDrop { - _ = tx.Exec("DROP INDEX IF EXISTS " + index).Error - } - - for _, table := range tablesToRename { - // Check if table exists before renaming - var exists bool - - err := tx.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Row().Scan(&exists) - if err != nil { - return fmt.Errorf("checking if table %s exists: %w", table, err) - } - - if exists { - // Drop old table if it exists from previous failed migration - _ = tx.Exec("DROP TABLE IF EXISTS " + table + "_old").Error - - // Rename current table to _old - err := tx.Exec("ALTER TABLE " + table + " RENAME TO " + table + "_old").Error - if err != nil { - return fmt.Errorf("renaming table %s to %s_old: %w", table, table, err) - } - } - } - - // Create new tables with correct schema - tableCreationSQL := []string{ - `CREATE TABLE users( - id integer PRIMARY KEY AUTOINCREMENT, - name text, - display_name text, - email text, - provider_identifier text, - provider text, - profile_pic_url text, - created_at datetime, - updated_at datetime, - deleted_at datetime -)`, - `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, - expiration datetime, - created_at 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, - expiration datetime, - last_seen datetime, - created_at datetime -)`, - `CREATE TABLE 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, - 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) -)`, - `CREATE TABLE policies( - id integer PRIMARY KEY AUTOINCREMENT, - data text, - created_at datetime, - updated_at datetime, - deleted_at datetime -)`, - } - - for _, createSQL := range tableCreationSQL { - err := tx.Exec(createSQL).Error - if err != nil { - return fmt.Errorf("creating new table: %w", err) - } - } - - // Copy data directly using SQL - dataCopySQL := []string{ - `INSERT INTO users (id, name, display_name, email, provider_identifier, provider, profile_pic_url, created_at, updated_at, deleted_at) - SELECT id, name, display_name, email, provider_identifier, provider, profile_pic_url, created_at, updated_at, deleted_at - FROM users_old`, - - `INSERT INTO pre_auth_keys (id, key, user_id, reusable, ephemeral, used, tags, expiration, created_at) - SELECT id, key, user_id, reusable, ephemeral, used, tags, expiration, created_at - FROM pre_auth_keys_old`, - - `INSERT INTO api_keys (id, prefix, hash, expiration, last_seen, created_at) - SELECT id, prefix, hash, expiration, last_seen, created_at - FROM api_keys_old`, - - `INSERT INTO nodes (id, machine_key, node_key, disco_key, endpoints, host_info, ipv4, ipv6, hostname, given_name, user_id, register_method, forced_tags, auth_key_id, last_seen, expiry, approved_routes, created_at, updated_at, deleted_at) - SELECT id, machine_key, node_key, disco_key, endpoints, host_info, ipv4, ipv6, hostname, given_name, user_id, register_method, forced_tags, auth_key_id, last_seen, expiry, approved_routes, created_at, updated_at, deleted_at - FROM nodes_old`, - - `INSERT INTO policies (id, data, created_at, updated_at, deleted_at) - SELECT id, data, created_at, updated_at, deleted_at - FROM policies_old`, - } - - for _, copySQL := range dataCopySQL { - err := tx.Exec(copySQL).Error - if err != nil { - return fmt.Errorf("copying data: %w", err) - } - } - - // Create indexes - indexes := []string{ - "CREATE INDEX idx_users_deleted_at ON users(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 idx_api_keys_prefix ON api_keys(prefix)", - "CREATE INDEX idx_policies_deleted_at ON policies(deleted_at)", - } - - for _, indexSQL := range indexes { - err := tx.Exec(indexSQL).Error - if err != nil { - return fmt.Errorf("creating index: %w", err) - } - } - - // Drop old tables only after everything succeeds - for _, table := range tablesToRename { - err := tx.Exec("DROP TABLE IF EXISTS " + table + "_old").Error - if err != nil { - log.Warn().Str("table", table+"_old").Err(err).Msg("failed to drop old table, but migration succeeded") - } - } - - log.Info().Msg("schema recreation completed successfully") - - return nil - }, - Rollback: func(db *gorm.DB) error { return nil }, - }, - // v0.27.1 - { - // 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 - }, - }, - { - // Drop all indices that are no longer in use and has existed. - // They potentially still present from broken migrations in the past. - // They should all be cleaned up by the db engine, but we are a bit - // conservative to ensure all our previous mess is cleaned up. - ID: "202511101554-drop-old-idx", - Migrate: func(tx *gorm.DB) error { - for _, oldIdx := range []struct{ name, table string }{ - {"idx_namespaces_deleted_at", "namespaces"}, - {"idx_routes_deleted_at", "routes"}, - {"idx_shared_machines_deleted_at", "shared_machines"}, - } { - err := tx.Migrator().DropIndex(oldIdx.table, oldIdx.name) - if err != nil { - log.Trace(). - Str("index", oldIdx.name). - Str("table", oldIdx.table). - Err(err). - Msg("Error dropping old index, continuing...") - } - } - - return nil - }, - Rollback: func(tx *gorm.DB) error { - return nil - }, - }, - - // Migrations **above** this points will be REMOVED in version **0.29.0** - // This is to clean up a lot of old migrations that is seldom used - // and carries a lot of technical debt. - // Any new migrations should be added after the comment below and follow - // the rules it sets out. - - // From this point, the following rules must be followed: + // Migrations start from v0.29.0; older databases are rejected by + // checkMinimumMigration and must upgrade to the latest 0.29.x first. + // + // Rules: // - 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. // - Never write migrations that requires foreign keys to be disabled. // - ALL errors in migrations must be handled properly. - - { - // Add columns for prefix and hash for pre auth keys, implementing - // them with the same security model as api keys. - ID: "202511011637-preauthkey-bcrypt", - Migrate: func(tx *gorm.DB) error { - // Check and add prefix column if it doesn't exist - if !tx.Migrator().HasColumn(&types.PreAuthKey{}, "prefix") { - err := tx.Migrator().AddColumn(&types.PreAuthKey{}, "prefix") - if err != nil { - return fmt.Errorf("adding prefix column: %w", err) - } - } - - // Check and add hash column if it doesn't exist - if !tx.Migrator().HasColumn(&types.PreAuthKey{}, "hash") { - err := tx.Migrator().AddColumn(&types.PreAuthKey{}, "hash") - if err != nil { - return fmt.Errorf("adding hash column: %w", err) - } - } - - // Create partial unique index to allow multiple legacy keys (NULL/empty prefix) - // while enforcing uniqueness for new bcrypt-based keys - err := tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''").Error - if err != nil { - return fmt.Errorf("creating prefix index: %w", err) - } - - return nil - }, - Rollback: func(db *gorm.DB) error { return nil }, - }, - { - ID: "202511122344-remove-newline-index", - Migrate: func(tx *gorm.DB) error { - // Reformat multi-line indexes to single-line for consistency - // This migration drops and recreates the three user identity indexes - // to match the single-line format expected by schema validation - - // Drop existing multi-line indexes - dropIndexes := []string{ - `DROP INDEX IF EXISTS idx_provider_identifier`, - `DROP INDEX IF EXISTS idx_name_provider_identifier`, - `DROP INDEX IF EXISTS idx_name_no_provider_identifier`, - } - - for _, dropSQL := range dropIndexes { - err := tx.Exec(dropSQL).Error - if err != nil { - return fmt.Errorf("dropping index: %w", err) - } - } - - // Recreate indexes in single-line format - createIndexes := []string{ - `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`, - } - - for _, createSQL := range createIndexes { - err := tx.Exec(createSQL).Error - if err != nil { - return fmt.Errorf("creating index: %w", err) - } - } - - return nil - }, - Rollback: func(db *gorm.DB) error { return nil }, - }, - { - // Rename forced_tags column to tags in nodes table. - // This must run after migration 202505141324 which creates tables with forced_tags. - ID: "202511131445-node-forced-tags-to-tags", - Migrate: func(tx *gorm.DB) error { - // Rename the column from forced_tags to tags - err := tx.Migrator().RenameColumn(&types.Node{}, "forced_tags", "tags") - if err != nil { - return fmt.Errorf("renaming forced_tags to tags: %w", err) - } - - return nil - }, - Rollback: func(db *gorm.DB) error { return nil }, - }, - { - // Migrate RequestTags from host_info JSON to tags column. - // In 0.27.x, tags from --advertise-tags (ValidTags) were stored only in - // host_info.RequestTags, not in the tags column (formerly forced_tags). - // This migration validates RequestTags against the policy's tagOwners - // and merges validated tags into the tags column. - // Fixes: https://github.com/juanfont/headscale/issues/3006 - ID: "202601121700-migrate-hostinfo-request-tags", - Migrate: func(tx *gorm.DB) error { - // 1. Load policy from file or database based on configuration - policyData, err := PolicyBytes(tx, cfg) - if err != nil { - log.Warn().Err(err).Msg("failed to load policy, skipping RequestTags migration (tags will be validated on node reconnect)") - return nil - } - - if len(policyData) == 0 { - log.Info().Msg("no policy found, skipping RequestTags migration (tags will be validated on node reconnect)") - return nil - } - - // 2. Load users and nodes to create PolicyManager - users, err := ListUsers(tx, nil) - if err != nil { - return fmt.Errorf("loading users for RequestTags migration: %w", err) - } - - nodes, err := ListNodes(tx) - if err != nil { - return fmt.Errorf("loading nodes for RequestTags migration: %w", err) - } - - // 3. Create PolicyManager (handles HuJSON parsing, groups, nested tags, etc.) - polMan, err := policy.NewPolicyManager(policyData, users, nodes.ViewSlice()) - if err != nil { - log.Warn().Err(err).Msg("failed to parse policy, skipping RequestTags migration (tags will be validated on node reconnect)") - return nil - } - - // 4. Process each node - for _, node := range nodes { - if node.Hostinfo == nil { - continue - } - - requestTags := node.Hostinfo.RequestTags - if len(requestTags) == 0 { - continue - } - - existingTags := node.Tags - - var validatedTags, rejectedTags []string - - nodeView := node.View() - - for _, tag := range requestTags { - if polMan.NodeCanHaveTag(nodeView, tag) { - if !slices.Contains(existingTags, tag) { - validatedTags = append(validatedTags, tag) - } - } else { - rejectedTags = append(rejectedTags, tag) - } - } - - if len(validatedTags) == 0 { - if len(rejectedTags) > 0 { - log.Debug(). - EmbedObject(node). - Strs("rejected_tags", rejectedTags). - Msg("RequestTags rejected during migration (not authorized)") - } - - continue - } - - mergedTags := append(slices.Clone(existingTags), validatedTags...) - slices.Sort(mergedTags) - mergedTags = slices.Compact(mergedTags) - - tagsJSON, err := json.Marshal(mergedTags) - if err != nil { - return fmt.Errorf("serializing merged tags for node %d: %w", node.ID, err) - } - - err = tx.Exec("UPDATE nodes SET tags = ? WHERE id = ?", string(tagsJSON), node.ID).Error - if err != nil { - return fmt.Errorf("updating tags for node %d: %w", node.ID, err) - } - - log.Info(). - EmbedObject(node). - Strs("validated_tags", validatedTags). - Strs("rejected_tags", rejectedTags). - Strs("existing_tags", existingTags). - Strs("merged_tags", mergedTags). - Msg("Migrated validated RequestTags from host_info to tags column") - } - - return nil - }, - Rollback: func(db *gorm.DB) error { return nil }, - }, - { - // Clear user_id on tagged nodes. - // Tagged nodes are owned by their tags, not a user. - // Previously user_id was kept as "created by" tracking, - // but this prevents deleting users whose nodes have been - // tagged, and the ON DELETE CASCADE FK would destroy the - // tagged nodes if the user were deleted. - // - // A nil tags slice marshals to the JSON literal 'null', so - // untagged nodes can carry tags='null'. That spelling must be - // excluded alongside '[]' and '' or untagged nodes lose their - // user. Nodes already detached by the earlier version of this - // migration are repaired by the recovery migration below. - // Fixes: https://github.com/juanfont/headscale/issues/3077 - // Fixes: https://github.com/juanfont/headscale/issues/3323 - ID: "202602201200-clear-tagged-node-user-id", - Migrate: func(tx *gorm.DB) error { - err := tx.Exec(` -UPDATE nodes -SET user_id = NULL -WHERE tags IS NOT NULL AND tags != '[]' AND tags != '' AND tags != 'null'; - `).Error - if err != nil { - return fmt.Errorf("clearing user_id on tagged nodes: %w", err) - } - - return nil - }, - 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 }, - }, { // Recover user_id on untagged nodes detached by the earlier // version of 202602201200-clear-tagged-node-user-id, which // treated tags='null' as tagged and cleared the user. This - // repairs databases that already upgraded to 0.29.0; fresh - // upgrades are protected by the fixed migration above and find - // nothing to repair. Recovery is best-effort: the owner is - // re-derived from the node's pre-auth key, so nodes registered - // via CLI/OIDC (no pre-auth key) cannot be recovered and must - // be reassigned manually. + // repairs databases that already upgraded to 0.29.0; databases + // that took the fixed migration find nothing to repair. + // Recovery is best-effort: the owner is re-derived from the + // node's pre-auth key, so nodes registered via CLI/OIDC (no + // pre-auth key) cannot be recovered and must be reassigned + // manually. // Fixes: https://github.com/juanfont/headscale/issues/3323 ID: "202606181200-recover-null-tags-node-user-id", Migrate: func(tx *gorm.DB) error { @@ -907,8 +232,8 @@ WHERE user_id IS NULL // permanently Expired and unable to re-authenticate. The // buggy writer is fixed, so this only repairs rows written // before the upgrade; a fixed server cannot recreate them. - // Match the tagged-node predicate the earlier - // clear-tagged-node-user-id migration uses (a nil tags slice + // Match the tagged-node predicate of 0.29's + // clear-tagged-node-user-id migration (a nil tags slice // marshals to 'null', so exclude it). // Fixes: https://github.com/juanfont/headscale/issues/3371 ID: "202607241200-clear-tagged-node-expiry", @@ -1159,27 +484,6 @@ func openDB(cfg types.DatabaseConfig) (*gorm.DB, error) { func runMigrations(cfg types.DatabaseConfig, dbConn *gorm.DB, migrations *gormigrate.Gormigrate) error { if cfg.Type == types.DatabaseSqlite { - // SQLite: Run the early migrations that GORM cannot handle safely with - // foreign keys enabled (route and pre-auth-key automigrations) with FK - // disabled, then run everything else with FK enabled. - // - // NO NEW MIGRATIONS SHOULD RUN WITH FK DISABLED. As of 2025-07-02, all - // new migrations must run with foreign keys enabled via the - // migrations.Migrate() call below. - if err := dbConn.Exec("PRAGMA foreign_keys = OFF").Error; err != nil { //nolint:noinlineerr - return fmt.Errorf("disabling foreign keys: %w", err) - } - - // Run up to and including the last migration that requires FK disabled. - if err := migrations.MigrateTo("202501311657"); err != nil { //nolint:noinlineerr - return fmt.Errorf("running migration 202501311657: %w", err) - } - - if err := dbConn.Exec("PRAGMA foreign_keys = ON").Error; err != nil { //nolint:noinlineerr - return fmt.Errorf("restoring foreign keys: %w", err) - } - - // Run the rest of the migrations if err := migrations.Migrate(); err != nil { //nolint:noinlineerr return err } diff --git a/hscontrol/db/db_test.go b/hscontrol/db/db_test.go index cfc31ebd..89d6be06 100644 --- a/hscontrol/db/db_test.go +++ b/hscontrol/db/db_test.go @@ -23,232 +23,6 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) { dbPath string wantFunc func(*testing.T, *HSDatabase) }{ - // at 14:15:06 ❯ go run ./cmd/headscale preauthkeys list - // ID | Key | Reusable | Ephemeral | Used | Expiration | Created | Tags - // 1 | 09b28f.. | false | false | false | 2024-09-27 | 2024-09-27 | tag:derp - // 2 | 3112b9.. | false | false | false | 2024-09-27 | 2024-09-27 | tag:derp - { - dbPath: "testdata/sqlite/failing-node-preauth-constraint_dump.sql", - wantFunc: func(t *testing.T, hsdb *HSDatabase) { - t.Helper() - // Comprehensive data preservation validation for node-preauth constraint issue - // Expected data from dump: 1 user, 2 api_keys, 6 nodes - - // Verify users data preservation - users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) { - return ListUsers(rx, nil) - }) - require.NoError(t, err) - assert.Len(t, users, 1, "should preserve all 1 user from original schema") - - // Verify api_keys data preservation - var apiKeyCount int - - err = hsdb.DB.Raw("SELECT COUNT(*) FROM api_keys").Scan(&apiKeyCount).Error - require.NoError(t, err) - assert.Equal(t, 2, apiKeyCount, "should preserve all 2 api_keys from original schema") - - // Verify nodes data preservation and field validation - nodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) { - return ListNodes(rx) - }) - require.NoError(t, err) - assert.Len(t, nodes, 6, "should preserve all 6 nodes from original schema") - - for _, node := range nodes { - assert.Falsef(t, node.MachineKey.IsZero(), "expected non zero machinekey") - assert.Contains(t, node.MachineKey.String(), "mkey:") - assert.Falsef(t, node.NodeKey.IsZero(), "expected non zero nodekey") - assert.Contains(t, node.NodeKey.String(), "nodekey:") - assert.Falsef(t, node.DiscoKey.IsZero(), "expected non zero discokey") - assert.Contains(t, node.DiscoKey.String(), "discokey:") - assert.Nil(t, node.AuthKey) - assert.Nil(t, node.AuthKeyID) - } - }, - }, - // Test for RequestTags migration (202601121700-migrate-hostinfo-request-tags) - // and forced_tags->tags rename migration (202511131445-node-forced-tags-to-tags) - // - // This test validates that: - // 1. The forced_tags column is renamed to tags - // 2. RequestTags from host_info are validated against policy tagOwners - // 3. Authorized tags are migrated to the tags column - // 4. Unauthorized tags are rejected - // 5. Existing tags are preserved - // 6. Group membership is evaluated for tag authorization - { - dbPath: "testdata/sqlite/request_tags_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, 7, "should have all 7 nodes") - - // Helper to find node by hostname - findNode := func(hostname string) *types.Node { - for _, n := range nodes { - if n.Hostname == hostname { - return n - } - } - - return nil - } - - // Node 1: user1 has RequestTags for tag:server (authorized) - // Expected: tags = ["tag:server"] - node1 := findNode("node1") - require.NotNil(t, node1, "node1 should exist") - assert.Contains(t, node1.Tags, "tag:server", "node1 should have tag:server migrated from RequestTags") - - // Node 2: user1 has RequestTags for tag:unauthorized (NOT authorized) - // Expected: tags = [] (unchanged) - node2 := findNode("node2") - require.NotNil(t, node2, "node2 should exist") - assert.Empty(t, node2.Tags, "node2 should have empty tags (unauthorized tag rejected)") - - // Node 3: user2 has RequestTags for tag:client (authorized) + existing tag:existing - // Expected: tags = ["tag:client", "tag:existing"] - node3 := findNode("node3") - require.NotNil(t, node3, "node3 should exist") - assert.Contains(t, node3.Tags, "tag:client", "node3 should have tag:client migrated from RequestTags") - assert.Contains(t, node3.Tags, "tag:existing", "node3 should preserve existing tag") - - // Node 4: user1 has RequestTags for tag:server which already exists - // Expected: tags = ["tag:server"] (no duplicates) - node4 := findNode("node4") - require.NotNil(t, node4, "node4 should exist") - assert.Equal(t, []string{"tag:server"}, node4.Tags.List(), "node4 should have tag:server without duplicates") //nolint:goconst // descriptive test assertions read better with the literal inline - - // Node 5: user2 has no RequestTags - // Expected: tags = [] (unchanged) - node5 := findNode("node5") - require.NotNil(t, node5, "node5 should exist") - assert.Empty(t, node5.Tags, "node5 should have empty tags (no RequestTags)") - - // Node 6: admin1 has RequestTags for tag:admin (authorized via group:admins) - // Expected: tags = ["tag:admin"] - node6 := findNode("node6") - require.NotNil(t, node6, "node6 should exist") - assert.Contains(t, node6.Tags, "tag:admin", "node6 should have tag:admin migrated via group membership") - - // Node 7: user1 has RequestTags for tag:server (authorized) and tag:forbidden (unauthorized) - // Expected: tags = ["tag:server"] (only authorized tag) - node7 := findNode("node7") - require.NotNil(t, node7, "node7 should exist") - assert.Contains(t, node7.Tags, "tag:server", "node7 should have tag:server migrated") - 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") - }, - }, - // Test for the clear-tagged-node-user-id migration - // (202602201200-clear-tagged-node-user-id). A nil tags slice - // marshals to the JSON literal 'null', so untagged nodes can carry - // tags='null' in the database. The migration must only clear - // user_id on genuinely tagged nodes, not on these untagged ones. - // Fixes: https://github.com/juanfont/headscale/issues/3323 - { - dbPath: "testdata/sqlite/null_tags_user_id_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, 4, "should have all 4 nodes") - - byHostname := make(map[string]*types.Node, len(nodes)) - for _, n := range nodes { - byHostname[n.Hostname] = n - } - - // Node 1 had tags='null' (untagged) and belonged to user2. - // The migration must NOT clear its user_id. - node1 := byHostname["node1"] - require.NotNil(t, node1, "node1 should exist") - assert.False(t, node1.IsTagged(), "node1 with tags='null' should be untagged") - require.NotNil(t, node1.UserID, "node1 should keep its user assigned") - assert.Equal(t, uint(2), *node1.UserID, "node1 should still belong to user2") - - // Node 2 is genuinely tagged; user_id must be cleared. - node2 := byHostname["node2"] - require.NotNil(t, node2, "node2 should exist") - assert.True(t, node2.IsTagged(), "node2 should be tagged") - assert.Nil(t, node2.UserID, "node2 (tagged) should have user_id cleared") - - // Node 3 had tags='[]' (untagged); user_id preserved. - node3 := byHostname["node3"] - require.NotNil(t, node3, "node3 should exist") - assert.False(t, node3.IsTagged(), "node3 with tags='[]' should be untagged") - require.NotNil(t, node3.UserID, "node3 should keep its user assigned") - assert.Equal(t, uint(1), *node3.UserID, "node3 should still belong to user1") - - // Node 4 had tags='' (untagged); user_id preserved. - node4 := byHostname["node4"] - require.NotNil(t, node4, "node4 should exist") - assert.False(t, node4.IsTagged(), "node4 with tags='' should be untagged") - require.NotNil(t, node4.UserID, "node4 should keep its user assigned") - assert.Equal(t, uint(1), *node4.UserID, "node4 should still belong to user1") - }, - }, // Test for the null-tags user_id recovery migration. Databases that // already upgraded to 0.29.0 had user_id wrongly cleared on untagged // nodes with tags='null'. The recovery migration re-derives user_id @@ -596,6 +370,24 @@ func dbForTestWithPath(t *testing.T, sqlFilePath string) *HSDatabase { return db } +// TestSQLiteRejectsPre029Database ensures a real pre-0.29 database is refused +// rather than silently skipping the migrations that were removed. +func TestSQLiteRejectsPre029Database(t *testing.T) { + dbPath := t.TempDir() + "/headscale_test.db" + + err := createSQLiteFromSQLFile("testdata/sqlite_too_old/headscale_0.26.1_dump.sql", dbPath) + require.NoError(t, err) + + _, err = NewHeadscaleDatabase(&types.Config{ + Database: types.DatabaseConfig{ + Type: "sqlite3", + Sqlite: types.SqliteConfig{Path: dbPath}, + }, + Policy: types.PolicyConfig{Mode: types.PolicyModeDB}, + }) + require.ErrorIs(t, err, errDatabaseTooOld) +} + // TestSQLiteAllTestdataMigrations tests migration compatibility across all SQLite schemas // in the testdata directory. It verifies they can be successfully migrated to the current // schema version. This test only validates migration success, not data integrity. diff --git a/hscontrol/db/testdata/sqlite/clear_tagged_node_expiry_migration_test.sql b/hscontrol/db/testdata/sqlite/clear_tagged_node_expiry_migration_test.sql index 22ac1eed..eb1abde4 100644 --- a/hscontrol/db/testdata/sqlite/clear_tagged_node_expiry_migration_test.sql +++ b/hscontrol/db/testdata/sqlite/clear_tagged_node_expiry_migration_test.sql @@ -10,10 +10,8 @@ PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; --- Migrations table: entries applied up to (but not including) the fix. The --- intervening expiry migrations (clear-zero-time) also run against this dump; --- their predicates (expiry < 1900) do not match the post-2000 dates below, so --- they leave these rows for the new migration to handle. +-- Migrations table: the v0.29.0 set, so only the post-0.29.0 migrations +-- (including the fix under test) run against this dump. CREATE TABLE `migrations` (`id` text,PRIMARY KEY (`id`)); INSERT INTO migrations VALUES('202312101416'); INSERT INTO migrations VALUES('202312101430'); @@ -38,6 +36,7 @@ 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'); +INSERT INTO migrations VALUES('202605221435-clear-zero-time-node-expiry'); -- 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); diff --git a/hscontrol/db/testdata/sqlite/failing-node-preauth-constraint_dump.sql b/hscontrol/db/testdata/sqlite/failing-node-preauth-constraint_dump.sql deleted file mode 100644 index 68069064..00000000 --- a/hscontrol/db/testdata/sqlite/failing-node-preauth-constraint_dump.sql +++ /dev/null @@ -1,34 +0,0 @@ -PRAGMA foreign_keys=OFF; -BEGIN TRANSACTION; -CREATE TABLE IF NOT EXISTS "api_keys" (`id` integer,`prefix` text UNIQUE,`hash` blob,`created_at` datetime,`expiration` datetime,`last_seen` datetime,PRIMARY KEY (`id`)); -INSERT INTO api_keys VALUES(1,'hFKcRjLyfw',X'243261243130242e68554a6739332e6658333061326457723637464f2e6146424c74726e4542474c6c746437597a4253534d6f3677326d3944664d61','2023-04-09 22:34:28.624250346+00:00','2023-07-08 22:34:28.559681279+00:00',NULL); -INSERT INTO api_keys VALUES(2,'88Wbitubag',X'243261243130246f7932506d53375033334b733861376e7745434f3665674e776e517659374b5474326a30686958446c6c55696c3568513948307665','2024-07-28 21:59:38.786936789+00:00','2024-10-26 21:59:38.724189498+00:00',NULL); -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'); -CREATE TABLE `policies` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`data` text); -CREATE TABLE IF NOT EXISTS "pre_auth_keys" (`id` integer,`key` text,`user_id` integer,`reusable` numeric,`ephemeral` numeric DEFAULT false,`used` numeric DEFAULT false,`created_at` datetime,`expiration` datetime,`tags` text,PRIMARY KEY (`id`),CONSTRAINT `fk_pre_auth_keys_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL); -CREATE TABLE IF NOT EXISTS "nodes" (`id` integer,`machine_key` text,`node_key` text,`disco_key` text,`hostname` text,`given_name` varchar(63),`user_id` integer,`register_method` text,`forced_tags` text,`auth_key_id` integer,`last_seen` datetime,`expiry` datetime,`host_info` text,`endpoints` text,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`ipv4` text,`ipv6` text,PRIMARY KEY (`id`),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`)); -INSERT INTO nodes VALUES(1,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e63','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c160554f','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57759','hostname_1','given_name1',1,'cli','["tag:sshclient","tag:ssh"]',0,'2025-02-05 16:46:13.960213431+00:00','0001-01-01 00:00:00+00:00','{}','[]','2023-03-30 23:18:17.612740902+00:00','2025-02-05 16:46:13.960284003+00:00',NULL,'100.64.0.1','fd7a:115c:a1e0::1'); -INSERT INTO nodes VALUES(2,'mkey:f63dda7495db68077080364ba4109f48dee7a59310b9ed4968beb40d038eb622','nodekey:8186817337049e092e6ea02507091d8e9686924d46ad0e74a90370ec0113c440','discokey:28a2df7e73b8196c6859c94329443a28f9605b2b83541b685c1db666bd835775','hostname_2','given_name2',1,'cli','["tag:sshclient"]',0,'2024-07-30 17:37:24.266006395+00:00','0001-01-01 00:00:00+00:00','{}','[]','2023-03-30 23:20:01.05202704+00:00','2024-07-30 17:37:24.266082813+00:00',NULL,'100.64.0.2','fd7a:115c:a1e0::2'); -INSERT INTO nodes VALUES(3,'mkey:0af53661fedf5143af3ea79e596928302e51c9fc9f0ea9ed1f2bb7d54778b80e','nodekey:8defd8272fd2851601158b2444fc8d1ab12b6187ec5db154b7a83bb75b2ce952','discokey:ba9d1ffac1997acbd8d281b8711699daa77ed91691772683ebbfdaafa2518a52','hostname_3','given_name3',1,'cli','["tag:ssh"]',0,'2025-02-05 16:48:00.460606473+00:00','0001-01-01 00:00:00+00:00','{}','[]','2023-03-30 23:36:04.930844845+00:00','2025-02-05 16:48:00.460679869+00:00',NULL,'100.64.0.3','fd7a:115c:a1e0::3'); -INSERT INTO nodes VALUES(4,'mkey:365e2055485de89e65e63c13e426b1ec5d5606327d63955b38be1d3f8cbbac6c','nodekey:996b9814e405f572fc0338f91b0c53f3a3a9a5b1ae0d2846d179195778d50909','discokey:ed72cb545b46b3e2ed0332f9cb4d7f4e774ea5834e2cbadc43c9bf7918ef2503','hostname_4','given_name4',1,'cli','["tag:ssh"]',0,'2025-02-05 16:48:00.460607206+00:00','0001-01-01 00:00:00+00:00','{}','[]','2023-03-31 15:51:56.149734121+00:00','2025-02-05 16:48:00.46092239+00:00',NULL,'100.64.0.4','fd7a:115c:a1e0::4'); -INSERT INTO nodes VALUES(5,'mkey:1d04be488182a66cd7df4596ac59a40613eac6465a331af9ac6c91bb70754a25','nodekey:9b617f3e7941ac70b76f0e40c55543173e0432d4a9bb8bcb8b25d93b60a5da0e','discokey:15834557115cb889e8362e7f2cae1cfd7e78e754cb7310cff6b5c5b5d3027e35','hostname_5','given_name5',1,'cli','["tag:sshclient","tag:ssh"]',0,'2023-04-21 15:07:38.796218079+00:00','0001-01-01 00:00:00+00:00','{}','[]','2023-04-21 13:16:19.148836255+00:00','2024-04-17 15:39:21.339518261+00:00',NULL,'100.64.0.5','fd7a:115c:a1e0::5'); -INSERT INTO nodes VALUES(6,'mkey:ed649503734e31eafad7f884ac8ee36ba0922c57cda8b6946cb439b1ed645676','nodekey:200484e66b43012eca81ec8850e4b5d1dd8fa538dfebdaac718f202cd2f1f955','discokey:600651ed2436ce5a49e71b3980f93070d888e6d65d608a64be29fdeed9f7bd6b','hostname_6','given_name6',1,'cli','["tag:ssh"]',0,'2023-07-09 16:56:18.876491583+00:00','0001-01-01 00:00:00+00:00','{}','[]','2023-05-07 10:30:54.520661376+00:00','2024-04-17 15:39:23.182648721+00:00',NULL,'100.64.0.6','fd7a:115c:a1e0::6'); -CREATE TABLE IF NOT EXISTS "routes" (`id` integer,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`node_id` integer NOT NULL,`prefix` text,`advertised` numeric,`enabled` numeric,`is_primary` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_nodes_routes` FOREIGN KEY (`node_id`) REFERENCES `nodes`(`id`) ON DELETE CASCADE); -CREATE TABLE IF NOT EXISTS "users" (`id` integer,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`name` text UNIQUE,`display_name` text,`email` text,`provider_identifier` text,`provider` text,`profile_pic_url` text,PRIMARY KEY (`id`)); -INSERT INTO users VALUES(1,'2023-03-30 23:08:54.151102578+00:00','2023-03-30 23:08:54.151102578+00:00',NULL,'username_1','display_name_1','email_1@example.com',NULL,NULL,NULL); -DELETE FROM sqlite_sequence; -CREATE UNIQUE INDEX `idx_api_keys_prefix` ON `api_keys`(`prefix`); -CREATE INDEX `idx_policies_deleted_at` ON `policies`(`deleted_at`); -CREATE INDEX `idx_routes_deleted_at` ON `routes`(`deleted_at`); -CREATE INDEX `idx_users_deleted_at` ON `users`(`deleted_at`); -COMMIT; diff --git a/hscontrol/db/testdata/sqlite/headscale_0.26.0-beta.1_dump.sql b/hscontrol/db/testdata/sqlite/headscale_0.26.0-beta.1_dump.sql deleted file mode 100644 index 62384198..00000000 --- a/hscontrol/db/testdata/sqlite/headscale_0.26.0-beta.1_dump.sql +++ /dev/null @@ -1,30 +0,0 @@ -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'); -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 `policies` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`data` text); -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,`approved_routes` text,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,CONSTRAINT `fk_nodes_auth_key` FOREIGN KEY (`auth_key_id`) REFERENCES `pre_auth_keys`(`id`),CONSTRAINT `fk_nodes_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE); -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; -COMMIT; diff --git a/hscontrol/db/testdata/sqlite/headscale_0.26.0-beta.2_dump.sql b/hscontrol/db/testdata/sqlite/headscale_0.26.0-beta.2_dump.sql deleted file mode 100644 index 284a4c4f..00000000 --- a/hscontrol/db/testdata/sqlite/headscale_0.26.0-beta.2_dump.sql +++ /dev/null @@ -1,31 +0,0 @@ -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'); -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; -COMMIT; diff --git a/hscontrol/db/testdata/sqlite/headscale_0.26.0_dump.sql b/hscontrol/db/testdata/sqlite/headscale_0.26.0_dump.sql deleted file mode 100644 index d91e38c9..00000000 --- a/hscontrol/db/testdata/sqlite/headscale_0.26.0_dump.sql +++ /dev/null @@ -1,32 +0,0 @@ -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; -COMMIT; diff --git a/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump-litestream.sql b/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump-litestream.sql deleted file mode 100644 index c8c05755..00000000 --- a/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump-litestream.sql +++ /dev/null @@ -1,34 +0,0 @@ -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 TABLE _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER); -CREATE TABLE _litestream_lock (id INTEGER); -COMMIT; 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 deleted file mode 100644 index d911e960..00000000 --- a/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql +++ /dev/null @@ -1,45 +0,0 @@ -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,`deleted_at` datetime,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,`deleted_at` datetime,PRIMARY KEY (`id`)); -CREATE TABLE `pre_auth_key_acl_tags` (`id` text,PRIMARY KEY (`id`)); -CREATE TABLE `routes` (`id` text,`deleted_at` datetime,PRIMARY KEY (`id`)); - -CREATE INDEX `idx_routes_deleted_at` ON `routes`(`deleted_at`); -CREATE INDEX `idx_namespaces_deleted_at` ON `namespaces`(`deleted_at`); -CREATE INDEX `idx_shared_machines_deleted_at` ON `shared_machines`(`deleted_at`); - -COMMIT; diff --git a/hscontrol/db/testdata/sqlite/headscale_0.29.3_dump.sql b/hscontrol/db/testdata/sqlite/headscale_0.29.3_dump.sql new file mode 100644 index 00000000..2972662f --- /dev/null +++ b/hscontrol/db/testdata/sqlite/headscale_0.29.3_dump.sql @@ -0,0 +1,83 @@ +-- Database created by a real headscale v0.29.3 binary (go build of tag +-- v0.29.3): `headscale serve` ran the migrations and wrote +-- database_versions; users, the API key and the bcrypt pre-auth keys were +-- created with the v0.29.3 CLI. The legacy plaintext pre-auth key and all +-- nodes were inserted by SQL against that schema; node 6 was inserted then +-- deleted so sqlite_sequence(nodes) > max(nodes.id). Dumped with `.dump`. +-- +-- Users: 1=alice, 2=bob +-- API key (id 1): +-- hskey-api-ZRVzG0vKkUb4-dqem7jxt7Aun0JqfZpbsvrBDYdQV-RK8S9qbiAAniiuTxIj73LeDUDukVYJBqmDh +-- Pre-auth keys: +-- 1 reusable, alice: +-- hskey-auth-H3XVw1W-6s4J-KTmfCUFG_4gJ8CuI5j3W67DX8eMnQJi6W8ToVK0esXMrPK5YTm_p8THq6VnH22-K +-- 2 single-use, alice, used=true: +-- hskey-auth-wVBSRrOq9_aQ-hg9vYfijlQDPFKx6lvJ4sT4376N6OV5IijEEQzRouXpgMENeWaozgPsjhepjSkxf +-- 3 ephemeral, bob: +-- hskey-auth-nBq0Csj0PIiH-qNvobKcN8PeuvzS8985BnYYxIdVhh9D6vnRaTUuochTgZyQrA8ius5yuVzD9WZLA +-- 4 tagged tag:server, no user: +-- hskey-auth-q3rudWOszD6G-Oy9BLGYYpGg-HdXjlwXQT8-9IfrcQ2aIdJfzhddkxQoSgN0JkdVEUxXboxS55mrH +-- 5 legacy plaintext, bob, reusable, ephemeral: +-- plaintextlegacykey0000000000000000000000000000 +-- Nodes (id -> auth_key_id): 1->1, 2->2, 3->3, 4->4 (tagged), 5->5 (ephemeral) +-- sqlite_sequence nodes=6; database_versions version='v0.29.3' + +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE `database_versions` (`id` integer PRIMARY KEY AUTOINCREMENT,`version` text NOT NULL,`updated_at` datetime); +INSERT INTO database_versions VALUES(1,'v0.29.3','2026-09-23 15:28:31.661895503+00:00'); +CREATE TABLE `migrations` (`id` text,PRIMARY KEY (`id`)); +INSERT INTO migrations VALUES('SCHEMA_INIT'); +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'); +INSERT INTO migrations VALUES('202605221435-clear-zero-time-node-expiry'); +INSERT INTO migrations VALUES('202606181200-recover-null-tags-node-user-id'); +INSERT INTO migrations VALUES('202607241200-clear-tagged-node-expiry'); +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,'2026-09-23 15:28:36.088806516+00:00','2026-09-23 15:28:36.088806516+00:00',NULL,'alice','','',NULL,'',''); +INSERT INTO users VALUES(2,'2026-09-23 15:28:36.136634424+00:00','2026-09-23 15:28:36.136634424+00:00',NULL,'bob','','',NULL,'',''); +CREATE TABLE `pre_auth_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`key` text,`prefix` text,`hash` blob,`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); +INSERT INTO pre_auth_keys VALUES(1,'','H3XVw1W-6s4J',x'243261243130244b3171432f416e3347537a31684f644c4f6b575468754d69386a4f59724235367356787378707a684e705a41776e77395551517232',1,1,0,0,'[]','2026-09-23 15:28:40.293817627+00:00','2036-09-20 15:28:40.293282175+00:00'); +INSERT INTO pre_auth_keys VALUES(2,'','wVBSRrOq9_aQ',x'243261243130244330316e45364e70736e504d3247467344546573682e394a56317149424d654a494944616450322e4f6e374e53556b79563341752e',1,0,0,1,'[]','2026-09-23 15:28:40.382033566+00:00','2036-09-20 15:28:40.381476756+00:00'); +INSERT INTO pre_auth_keys VALUES(3,'','nBq0Csj0PIiH',x'24326124313024387338686568495a4e4d6676424b6d76714c686a417573544933674375482e68716b323377734f63593349643832532f70765a6a79',2,0,1,0,'[]','2026-09-23 15:28:40.470602839+00:00','2036-09-20 15:28:40.47008079+00:00'); +INSERT INTO pre_auth_keys VALUES(4,'','q3rudWOszD6G',x'24326124313024674f38324c4c7632775245357973636c2f6357397965315834794869534d632f4757396a7a4755447078314b715162725468632e6d',NULL,0,0,0,'["tag:server"]','2026-09-23 15:28:40.55735853+00:00','2036-09-20 15:28:40.556981794+00:00'); +INSERT INTO pre_auth_keys VALUES(5,'plaintextlegacykey0000000000000000000000000000',NULL,NULL,2,1,1,0,'[]','2026-09-23 15:29:00.000000000+00:00','2036-09-20 15:29:00.000000000+00:00'); +CREATE TABLE `api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`prefix` text,`hash` blob,`created_at` datetime,`expiration` datetime,`last_seen` datetime); +INSERT INTO api_keys VALUES(1,'ZRVzG0vKkUb4',x'24326124313024476c5333616f33647a63716531472e4649593263594f52567447796d65316b2e7a445136495a576a324a6357493465306b57593832','2026-09-23 15:28:40.276116989+00:00','2036-09-20 15:28:40.206224138+00:00',NULL); +CREATE TABLE `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,`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`)); +INSERT INTO nodes VALUES(1,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e01','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605501','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57701','[]','{}','100.64.0.1','fd7a:115c:a1e0::1','node1','node1',1,'authkey','[]',1,NULL,'2026-09-23 15:30:01.000000000+00:00','[]','2026-09-23 15:30:01.000000000+00:00','2026-09-23 15:30:01.000000000+00:00',NULL); +INSERT INTO nodes VALUES(2,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e02','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605502','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57702','[]','{}','100.64.0.2','fd7a:115c:a1e0::2','node2','node2',1,'authkey','[]',2,NULL,'2026-09-23 15:30:02.000000000+00:00','[]','2026-09-23 15:30:02.000000000+00:00','2026-09-23 15:30:02.000000000+00:00',NULL); +INSERT INTO nodes VALUES(3,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e03','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605503','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57703','[]','{}','100.64.0.3','fd7a:115c:a1e0::3','node3','node3',2,'authkey','[]',3,NULL,'2026-09-23 15:30:03.000000000+00:00','[]','2026-09-23 15:30:03.000000000+00:00','2026-09-23 15:30:03.000000000+00:00',NULL); +INSERT INTO nodes VALUES(4,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e04','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605504','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57704','[]','{}','100.64.0.4','fd7a:115c:a1e0::4','node4','node4',NULL,'authkey','["tag:server"]',4,NULL,'2026-09-23 15:30:04.000000000+00:00','[]','2026-09-23 15:30:04.000000000+00:00','2026-09-23 15:30:04.000000000+00:00',NULL); +INSERT INTO nodes VALUES(5,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e05','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605505','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57705','[]','{}','100.64.0.5','fd7a:115c:a1e0::5','node5','node5',2,'authkey','[]',5,NULL,'2026-09-23 15:30:05.000000000+00:00','[]','2026-09-23 15:30:05.000000000+00:00','2026-09-23 15:30:05.000000000+00:00',NULL); +CREATE TABLE `policies` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`data` text); +PRAGMA writable_schema=ON; +CREATE TABLE IF NOT EXISTS sqlite_sequence(name,seq); +DELETE FROM sqlite_sequence; +INSERT INTO sqlite_sequence VALUES('database_versions',1); +INSERT INTO sqlite_sequence VALUES('users',2); +INSERT INTO sqlite_sequence VALUES('api_keys',1); +INSERT INTO sqlite_sequence VALUES('pre_auth_keys',5); +INSERT INTO sqlite_sequence VALUES('nodes',6); +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 idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''; +PRAGMA writable_schema=OFF; +COMMIT; diff --git a/hscontrol/db/testdata/sqlite/null_tags_user_id_migration_test.sql b/hscontrol/db/testdata/sqlite/null_tags_user_id_migration_test.sql deleted file mode 100644 index c45d0ff5..00000000 --- a/hscontrol/db/testdata/sqlite/null_tags_user_id_migration_test.sql +++ /dev/null @@ -1,85 +0,0 @@ --- Test SQL dump for the clear-tagged-node-user-id migration --- (202602201200-clear-tagged-node-user-id) against nodes whose tags --- column holds the JSON literal 'null'. --- --- A nil Strings slice marshals to the JSON literal `null`, so pre-0.29 --- databases contain untagged nodes with tags='null'. The migration's --- WHERE clause (tags IS NOT NULL AND tags != '[]' AND tags != '') treats --- the 4-character string 'null' as "tagged" and wrongly clears user_id, --- detaching the node from its owning user on upgrade. --- Fixes: https://github.com/juanfont/headscale/issues/3323 - -PRAGMA foreign_keys=OFF; -BEGIN TRANSACTION; - --- Migrations table: every entry BEFORE clear-tagged-node-user-id has been --- applied. That 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'); - --- 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); -INSERT INTO users VALUES(2,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'user2','User Two','user2@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: tags='null' (untagged, nil slice marshalled to JSON null), owned by user2. --- After migration: user_id MUST be preserved (this is the bug). -INSERT INTO nodes VALUES(1,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e01','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605501','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57701','[]','{}','100.64.0.1','fd7a:115c:a1e0::1','node1','node1',2,'cli','null',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 2: genuinely tagged, owned by user1. --- After migration: user_id MUST be cleared to NULL (tagged nodes are owned by tags). -INSERT INTO nodes VALUES(2,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e02','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605502','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57702','[]','{}','100.64.0.2','fd7a:115c:a1e0::2','node2','node2',1,'cli','["tag:server"]',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: empty-array tags (untagged), owned by user1. --- After migration: user_id MUST be 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',NULL,'[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); - --- Node 4: empty-string tags (untagged), owned by user1. --- After migration: user_id MUST be 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',NULL,'[]','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',2); -INSERT INTO sqlite_sequence VALUES('nodes',4); -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; diff --git a/hscontrol/db/testdata/sqlite/request_tags_migration_test.sql b/hscontrol/db/testdata/sqlite/request_tags_migration_test.sql deleted file mode 100644 index 6a6c1568..00000000 --- a/hscontrol/db/testdata/sqlite/request_tags_migration_test.sql +++ /dev/null @@ -1,119 +0,0 @@ --- Test SQL dump for RequestTags migration (202601121700-migrate-hostinfo-request-tags) --- and forced_tags->tags rename migration (202511131445-node-forced-tags-to-tags) --- --- This dump simulates a 0.27.x database where: --- - Tags from --advertise-tags were stored only in host_info.RequestTags --- - The tags column is still named forced_tags --- --- Test scenarios: --- 1. Node with RequestTags that user is authorized for (should be migrated) --- 2. Node with RequestTags that user is NOT authorized for (should be rejected) --- 3. Node with existing forced_tags that should be preserved --- 4. Node with RequestTags that overlap with existing tags (no duplicates) --- 5. Node without RequestTags (should be unchanged) --- 6. Node with RequestTags via group membership (should be migrated) - -PRAGMA foreign_keys=OFF; -BEGIN TRANSACTION; - --- Migrations table - includes all migrations BEFORE the two tag migrations -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'); --- Note: 202511131445-node-forced-tags-to-tags is NOT included - it will run --- Note: 202601121700-migrate-hostinfo-request-tags is NOT included - it will run - --- Users table --- Note: User names must match the usernames in the policy (with @) -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@example.com','User One','user1@example.com',NULL,NULL,NULL); -INSERT INTO users VALUES(2,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'user2@example.com','User Two','user2@example.com',NULL,NULL,NULL); -INSERT INTO users VALUES(3,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'admin1@example.com','Admin One','admin1@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 - using OLD schema with forced_tags (not tags) -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`)); - --- Node 1: user1 owns it, has RequestTags for tag:server (user1 is authorized for this tag) --- Expected: tag:server should be added to tags -INSERT INTO nodes VALUES(1,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e01','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605501','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57701','[]','{"RequestTags":["tag:server"]}','100.64.0.1','fd7a:115c:a1e0::1','node1','node1',1,'oidc','[]',NULL,'0001-01-01 00:00:00+00:00','2024-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: user1 owns it, has RequestTags for tag:unauthorized (user1 is NOT authorized for this tag) --- Expected: tag:unauthorized should be rejected, tags stays empty -INSERT INTO nodes VALUES(2,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e02','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605502','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57702','[]','{"RequestTags":["tag:unauthorized"]}','100.64.0.2','fd7a:115c:a1e0::2','node2','node2',1,'oidc','[]',NULL,'0001-01-01 00:00:00+00:00','2024-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 3: user2 owns it, has RequestTags for tag:client (user2 is authorized) --- Also has existing forced_tags that should be preserved --- Expected: tag:client added, tag:existing preserved -INSERT INTO nodes VALUES(3,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e03','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605503','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57703','[]','{"RequestTags":["tag:client"]}','100.64.0.3','fd7a:115c:a1e0::3','node3','node3',2,'oidc','["tag:existing"]',NULL,'0001-01-01 00:00:00+00:00','2024-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: user1 owns it, has RequestTags for tag:server which already exists in forced_tags --- Expected: no duplicates, tags should be ["tag:server"] -INSERT INTO nodes VALUES(4,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e04','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605504','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57704','[]','{"RequestTags":["tag:server"]}','100.64.0.4','fd7a:115c:a1e0::4','node4','node4',1,'oidc','["tag:server"]',NULL,'0001-01-01 00:00:00+00:00','2024-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: user2 owns it, no RequestTags in host_info --- Expected: tags unchanged (empty) -INSERT INTO nodes VALUES(5,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e05','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605505','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57705','[]','{}','100.64.0.5','fd7a:115c:a1e0::5','node5','node5',2,'oidc','[]',NULL,'0001-01-01 00:00:00+00:00','2024-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 6: admin1 owns it, has RequestTags for tag:admin (admin1 is in group:admins which owns tag:admin) --- Expected: tag:admin should be added via group membership -INSERT INTO nodes VALUES(6,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e06','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605506','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57706','[]','{"RequestTags":["tag:admin"]}','100.64.0.6','fd7a:115c:a1e0::6','node6','node6',3,'oidc','[]',NULL,'0001-01-01 00:00:00+00:00','2024-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 7: user1 owns it, has multiple RequestTags (tag:server authorized, tag:forbidden not authorized) --- Expected: tag:server added, tag:forbidden rejected -INSERT INTO nodes VALUES(7,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e07','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605507','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57707','[]','{"RequestTags":["tag:server","tag:forbidden"]}','100.64.0.7','fd7a:115c:a1e0::7','node7','node7',1,'oidc','[]',NULL,'0001-01-01 00:00:00+00:00','2024-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 with tagOwners defining who can use which tags --- Note: Usernames in policy must contain @ (e.g., user1@example.com or just user1@) -CREATE TABLE `policies` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`data` text); -INSERT INTO policies VALUES(1,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'{ - "groups": { - "group:admins": ["admin1@example.com"] - }, - "tagOwners": { - "tag:server": ["user1@example.com"], - "tag:client": ["user1@example.com", "user2@example.com"], - "tag:admin": ["group:admins"] - }, - "acls": [ - {"action": "accept", "src": ["*"], "dst": ["*:*"]} - ] -}'); - --- Indexes (using exact format expected by schema validation) -DELETE FROM sqlite_sequence; -INSERT INTO sqlite_sequence VALUES('users',3); -INSERT INTO sqlite_sequence VALUES('nodes',7); -INSERT INTO sqlite_sequence VALUES('policies',1); -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; diff --git a/hscontrol/db/testdata/sqlite/zero_time_expiry_migration_test.sql b/hscontrol/db/testdata/sqlite/zero_time_expiry_migration_test.sql deleted file mode 100644 index 9a6d3185..00000000 --- a/hscontrol/db/testdata/sqlite/zero_time_expiry_migration_test.sql +++ /dev/null @@ -1,82 +0,0 @@ --- 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; diff --git a/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump.sql b/hscontrol/db/testdata/sqlite_too_old/headscale_0.26.1_dump.sql similarity index 100% rename from hscontrol/db/testdata/sqlite/headscale_0.26.1_dump.sql rename to hscontrol/db/testdata/sqlite_too_old/headscale_0.26.1_dump.sql diff --git a/hscontrol/db/versioncheck.go b/hscontrol/db/versioncheck.go index 199b5964..c2506a1f 100644 --- a/hscontrol/db/versioncheck.go +++ b/hscontrol/db/versioncheck.go @@ -22,6 +22,16 @@ var errVersionMajorChange = errors.New("major version change not supported") var errVersionParse = errors.New("cannot parse version") +var errDatabaseTooOld = errors.New("database predates the minimum supported version") + +// minimumMigrationID is the last migration shipped in v0.29.0. Migrations up +// to it were removed from the list, so a database that has recorded neither it +// nor a later one would silently skip them. +// +// TODO(kradalby): in 0.31, bump to the last 0.30 migration when the 0.29-era +// migrations are removed. +const minimumMigrationID = "202605221435-clear-zero-time-node-expiry" + var errVersionFormat = errors.New( "version does not follow semver major.minor.patch format", ) @@ -173,6 +183,45 @@ func isDev(version string) bool { return ok } +// checkMinimumMigration rejects non-fresh databases that have not reached +// v0.29.0. It keys on the migrations table rather than database_versions, +// which only exists since 0.29.0 and is skipped for dev builds. +func checkMinimumMigration(db *gorm.DB) error { + if !db.Migrator().HasTable("migrations") { + return nil + } + + var total, found int64 + + err := db.Table("migrations").Count(&total).Error + if err != nil { + return fmt.Errorf("counting migrations: %w", err) + } + + if total == 0 { + return nil + } + + // IDs start with a timestamp, so they order chronologically as strings. A + // database created fresh by this release records only newer IDs. + err = db.Table("migrations"). + Where("id >= ? AND id != ?", minimumMigrationID, "SCHEMA_INIT"). + Count(&found).Error + if err != nil { + return fmt.Errorf("checking minimum migration: %w", err) + } + + if found == 0 { + return fmt.Errorf( + "upgrade to the latest v0.29.x release first, "+ + "release page: https://github.com/juanfont/headscale/releases: %w", + errDatabaseTooOld, + ) + } + + return nil +} + // checkVersionUpgradePath verifies that the running headscale version // is compatible with the version that last used this database. // diff --git a/hscontrol/db/versioncheck_test.go b/hscontrol/db/versioncheck_test.go index 87089ca5..4e391351 100644 --- a/hscontrol/db/versioncheck_test.go +++ b/hscontrol/db/versioncheck_test.go @@ -511,3 +511,39 @@ func checkVersionUpgradePathFromVersions(db *gorm.DB, currentVersion string) err return fmt.Errorf("downgrading: %w", errVersionDowngrade) } } + +func TestCheckMinimumMigration(t *testing.T) { + tests := []struct { + name string + ids []string // nil means no migrations table + wantErr bool + }{ + {name: "no migrations table", ids: nil}, + {name: "empty migrations table", ids: []string{}}, + {name: "0.29 database", ids: []string{"SCHEMA_INIT", minimumMigrationID}}, + {name: "created fresh by this release", ids: []string{"SCHEMA_INIT", "202606181200-recover-null-tags-node-user-id"}}, + {name: "0.28 database", ids: []string{"SCHEMA_INIT", "202601121700-migrate-hostinfo-request-tags"}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + require.NoError(t, err) + + if tt.ids != nil { + require.NoError(t, db.Exec("CREATE TABLE migrations (id varchar(255) PRIMARY KEY)").Error) + + for _, id := range tt.ids { + require.NoError(t, db.Exec("INSERT INTO migrations (id) VALUES (?)", id).Error) + } + } + + err = checkMinimumMigration(db) + if tt.wantErr { + require.ErrorIs(t, err, errDatabaseTooOld) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/hscontrol/types/routes.go b/hscontrol/types/routes.go deleted file mode 100644 index ac54f004..00000000 --- a/hscontrol/types/routes.go +++ /dev/null @@ -1,28 +0,0 @@ -package types - -import ( - "net/netip" - - "gorm.io/gorm" -) - -// Deprecated: Approval of routes is denormalised onto the relevant node. -// Struct is kept for GORM migrations only. -type Route struct { - gorm.Model - - NodeID uint64 `gorm:"not null"` - Node *Node - - Prefix netip.Prefix `gorm:"serializer:text"` - - // Advertised is now only stored as part of [Node.Hostinfo]. - Advertised bool - - // Enabled is stored directly on the node as [Node.ApprovedRoutes]. - Enabled bool - - // IsPrimary is only determined in memory as it is only relevant - // when the server is up. - IsPrimary bool -}