mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-30 20:09:36 +09:00
db: drop migrations predating 0.29
Only 0.29.x -> 0.30 upgrades are supported; checkMinimumMigration refuses older databases instead of silently skipping the removed steps.
This commit is contained in:
committed by
Kristoffer Dalby
parent
04d1e3c83f
commit
e90500e3a9
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+18
-714
@@ -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
|
||||
}
|
||||
|
||||
+18
-226
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
-45
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user