diff --git a/cmd/headscale/cli/policy.go b/cmd/headscale/cli/policy.go index f99d5390..2aaebcfa 100644 --- a/cmd/headscale/cli/policy.go +++ b/cmd/headscale/cli/policy.go @@ -69,8 +69,7 @@ var getPolicy = &cobra.Command{ } d, err := db.NewHeadscaleDatabase( - cfg.Database, - cfg.BaseDomain, + cfg, nil, ) if err != nil { @@ -145,8 +144,7 @@ var setPolicy = &cobra.Command{ } d, err := db.NewHeadscaleDatabase( - cfg.Database, - cfg.BaseDomain, + cfg, nil, ) if err != nil { diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index ad1a8a25..a1429aa6 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -15,6 +15,7 @@ import ( "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" @@ -44,29 +45,19 @@ const ( contextTimeoutSecs = 10 ) -// KV is a key-value store in a psql table. For future use... -// TODO(kradalby): Is this used for anything? -type KV struct { - Key string - Value string -} - type HSDatabase struct { DB *gorm.DB - cfg *types.DatabaseConfig + cfg *types.Config regCache *zcache.Cache[types.RegistrationID, types.RegisterNode] - - baseDomain string } -// TODO(kradalby): assemble this struct from toptions or something typed -// rather than arguments. +// NewHeadscaleDatabase creates a new database connection and runs migrations. +// It accepts the full configuration to allow migrations access to policy settings. func NewHeadscaleDatabase( - cfg types.DatabaseConfig, - baseDomain string, + cfg *types.Config, regCache *zcache.Cache[types.RegistrationID, types.RegisterNode], ) (*HSDatabase, error) { - dbConn, err := openDB(cfg) + dbConn, err := openDB(cfg.Database) if err != nil { return nil, err } @@ -253,7 +244,7 @@ AND auth_key_id NOT IN ( ID: "202507021200", Migrate: func(tx *gorm.DB) error { // Only run on SQLite - if cfg.Type != types.DatabaseSqlite { + if cfg.Database.Type != types.DatabaseSqlite { log.Info().Msg("Skipping schema migration on non-SQLite database") return nil } @@ -592,6 +583,112 @@ AND auth_key_id NOT IN ( }, 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) + 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(). + Uint64("node.id", uint64(node.ID)). + Str("node.name", node.Hostname). + Strs("rejected_tags", rejectedTags). + Msg("RequestTags rejected during migration (not authorized)") + } + + continue + } + + mergedTags := append(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(). + Uint64("node.id", uint64(node.ID)). + Str("node.name", node.Hostname). + 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 }, + }, }, ) @@ -648,7 +745,8 @@ AND auth_key_id NOT IN ( return nil }) - if err := runMigrations(cfg, dbConn, migrations); err != nil { + err = runMigrations(cfg.Database, dbConn, migrations) + if err != nil { return nil, fmt.Errorf("migration failed: %w", err) } @@ -656,7 +754,7 @@ AND auth_key_id NOT IN ( // This is currently only done on sqlite as squibble does not // support Postgres and we use our sqlite schema as our source of // truth. - if cfg.Type == types.DatabaseSqlite { + if cfg.Database.Type == types.DatabaseSqlite { sqlConn, err := dbConn.DB() if err != nil { return nil, fmt.Errorf("getting DB from gorm: %w", err) @@ -688,10 +786,8 @@ AND auth_key_id NOT IN ( db := HSDatabase{ DB: dbConn, - cfg: &cfg, + cfg: cfg, regCache: regCache, - - baseDomain: baseDomain, } return &db, err @@ -934,7 +1030,7 @@ func (hsdb *HSDatabase) Close() error { return err } - if hsdb.cfg.Type == types.DatabaseSqlite && hsdb.cfg.Sqlite.WriteAheadLog { + if hsdb.cfg.Database.Type == types.DatabaseSqlite && hsdb.cfg.Database.Sqlite.WriteAheadLog { db.Exec("VACUUM") } diff --git a/hscontrol/db/db_test.go b/hscontrol/db/db_test.go index 9d534269..3cd0d14e 100644 --- a/hscontrol/db/db_test.go +++ b/hscontrol/db/db_test.go @@ -67,6 +67,83 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) { } }, }, + // 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, "node4 should have tag:server without duplicates") + + // 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)") + }, + }, } for _, tt := range tests { @@ -288,13 +365,17 @@ func dbForTestWithPath(t *testing.T, sqlFilePath string) *HSDatabase { } db, err := NewHeadscaleDatabase( - types.DatabaseConfig{ - Type: "sqlite3", - Sqlite: types.SqliteConfig{ - Path: dbPath, + &types.Config{ + Database: types.DatabaseConfig{ + Type: "sqlite3", + Sqlite: types.SqliteConfig{ + Path: dbPath, + }, + }, + Policy: types.PolicyConfig{ + Mode: types.PolicyModeDB, }, }, - "", emptyCache(), ) if err != nil { @@ -343,13 +424,17 @@ func TestSQLiteAllTestdataMigrations(t *testing.T) { require.NoError(t, err) _, err = NewHeadscaleDatabase( - types.DatabaseConfig{ - Type: "sqlite3", - Sqlite: types.SqliteConfig{ - Path: dbPath, + &types.Config{ + Database: types.DatabaseConfig{ + Type: "sqlite3", + Sqlite: types.SqliteConfig{ + Path: dbPath, + }, + }, + Policy: types.PolicyConfig{ + Mode: types.PolicyModeDB, }, }, - "", emptyCache(), ) require.NoError(t, err) diff --git a/hscontrol/db/policy.go b/hscontrol/db/policy.go index 49b419b5..bdc8af41 100644 --- a/hscontrol/db/policy.go +++ b/hscontrol/db/policy.go @@ -2,8 +2,10 @@ package db import ( "errors" + "os" "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -24,14 +26,22 @@ func (hsdb *HSDatabase) SetPolicy(policy string) (*types.Policy, error) { // GetPolicy returns the latest policy in the database. func (hsdb *HSDatabase) GetPolicy() (*types.Policy, error) { + return GetPolicy(hsdb.DB) +} + +// GetPolicy returns the latest policy from the database. +// This standalone function can be used in contexts where HSDatabase is not available, +// such as during migrations. +func GetPolicy(tx *gorm.DB) (*types.Policy, error) { var p types.Policy // Query: // SELECT * FROM policies ORDER BY id DESC LIMIT 1; - if err := hsdb.DB. + err := tx. Order("id DESC"). Limit(1). - First(&p).Error; err != nil { + First(&p).Error + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, types.ErrPolicyNotFound } @@ -41,3 +51,41 @@ func (hsdb *HSDatabase) GetPolicy() (*types.Policy, error) { return &p, nil } + +// PolicyBytes loads policy configuration from file or database based on the configured mode. +// Returns nil if no policy is configured, which is valid. +// This standalone function can be used in contexts where HSDatabase is not available, +// such as during migrations. +func PolicyBytes(tx *gorm.DB, cfg *types.Config) ([]byte, error) { + switch cfg.Policy.Mode { + case types.PolicyModeFile: + path := cfg.Policy.Path + + // It is fine to start headscale without a policy file. + if len(path) == 0 { + return nil, nil + } + + absPath := util.AbsolutePathFromConfigPath(path) + + return os.ReadFile(absPath) + + case types.PolicyModeDB: + p, err := GetPolicy(tx) + if err != nil { + if errors.Is(err, types.ErrPolicyNotFound) { + return nil, nil + } + + return nil, err + } + + if p.Data == "" { + return nil, nil + } + + return []byte(p.Data), nil + } + + return nil, nil +} diff --git a/hscontrol/db/suite_test.go b/hscontrol/db/suite_test.go index 4ebccbdd..15a85cf8 100644 --- a/hscontrol/db/suite_test.go +++ b/hscontrol/db/suite_test.go @@ -23,13 +23,17 @@ func newSQLiteTestDB() (*HSDatabase, error) { zerolog.SetGlobalLevel(zerolog.Disabled) db, err := NewHeadscaleDatabase( - types.DatabaseConfig{ - Type: types.DatabaseSqlite, - Sqlite: types.SqliteConfig{ - Path: tmpDir + "/headscale_test.db", + &types.Config{ + Database: types.DatabaseConfig{ + Type: types.DatabaseSqlite, + Sqlite: types.SqliteConfig{ + Path: tmpDir + "/headscale_test.db", + }, + }, + Policy: types.PolicyConfig{ + Mode: types.PolicyModeDB, }, }, - "", emptyCache(), ) if err != nil { @@ -72,18 +76,22 @@ func newHeadscaleDBFromPostgresURL(t *testing.T, pu *url.URL) *HSDatabase { port, _ := strconv.Atoi(pu.Port()) db, err := NewHeadscaleDatabase( - types.DatabaseConfig{ - Type: types.DatabasePostgres, - Postgres: types.PostgresConfig{ - Host: pu.Hostname(), - User: pu.User.Username(), - Name: strings.TrimLeft(pu.Path, "/"), - Pass: pass, - Port: port, - Ssl: "disable", + &types.Config{ + Database: types.DatabaseConfig{ + Type: types.DatabasePostgres, + Postgres: types.PostgresConfig{ + Host: pu.Hostname(), + User: pu.User.Username(), + Name: strings.TrimLeft(pu.Path, "/"), + Pass: pass, + Port: port, + Ssl: "disable", + }, + }, + Policy: types.PolicyConfig{ + Mode: types.PolicyModeDB, }, }, - "", emptyCache(), ) if err != nil { diff --git a/hscontrol/db/testdata/sqlite/request_tags_migration_test.sql b/hscontrol/db/testdata/sqlite/request_tags_migration_test.sql new file mode 100644 index 00000000..6a6c1568 --- /dev/null +++ b/hscontrol/db/testdata/sqlite/request_tags_migration_test.sql @@ -0,0 +1,119 @@ +-- Test SQL dump for RequestTags migration (202601121700-migrate-hostinfo-request-tags) +-- and forced_tags->tags rename migration (202511131445-node-forced-tags-to-tags) +-- +-- This dump simulates a 0.27.x database where: +-- - Tags from --advertise-tags were stored only in host_info.RequestTags +-- - The tags column is still named forced_tags +-- +-- Test scenarios: +-- 1. Node with RequestTags that user is authorized for (should be migrated) +-- 2. Node with RequestTags that user is NOT authorized for (should be rejected) +-- 3. Node with existing forced_tags that should be preserved +-- 4. Node with RequestTags that overlap with existing tags (no duplicates) +-- 5. Node without RequestTags (should be unchanged) +-- 6. Node with RequestTags via group membership (should be migrated) + +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; + +-- Migrations table - includes all migrations BEFORE the two tag migrations +CREATE TABLE `migrations` (`id` text,PRIMARY KEY (`id`)); +INSERT INTO migrations VALUES('202312101416'); +INSERT INTO migrations VALUES('202312101430'); +INSERT INTO migrations VALUES('202402151347'); +INSERT INTO migrations VALUES('2024041121742'); +INSERT INTO migrations VALUES('202406021630'); +INSERT INTO migrations VALUES('202409271400'); +INSERT INTO migrations VALUES('202407191627'); +INSERT INTO migrations VALUES('202408181235'); +INSERT INTO migrations VALUES('202501221827'); +INSERT INTO migrations VALUES('202501311657'); +INSERT INTO migrations VALUES('202502070949'); +INSERT INTO migrations VALUES('202502131714'); +INSERT INTO migrations VALUES('202502171819'); +INSERT INTO migrations VALUES('202505091439'); +INSERT INTO migrations VALUES('202505141324'); +INSERT INTO migrations VALUES('202507021200'); +INSERT INTO migrations VALUES('202510311551'); +INSERT INTO migrations VALUES('202511101554-drop-old-idx'); +INSERT INTO migrations VALUES('202511011637-preauthkey-bcrypt'); +INSERT INTO migrations VALUES('202511122344-remove-newline-index'); +-- Note: 202511131445-node-forced-tags-to-tags is NOT included - it will run +-- Note: 202601121700-migrate-hostinfo-request-tags is NOT included - it will run + +-- Users table +-- Note: User names must match the usernames in the policy (with @) +CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`name` text,`display_name` text,`email` text,`provider_identifier` text,`provider` text,`profile_pic_url` text); +INSERT INTO users VALUES(1,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'user1@example.com','User One','user1@example.com',NULL,NULL,NULL); +INSERT INTO users VALUES(2,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'user2@example.com','User Two','user2@example.com',NULL,NULL,NULL); +INSERT INTO users VALUES(3,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'admin1@example.com','Admin One','admin1@example.com',NULL,NULL,NULL); + +-- Pre-auth keys table +CREATE TABLE `pre_auth_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`key` text,`user_id` integer,`reusable` numeric,`ephemeral` numeric DEFAULT false,`used` numeric DEFAULT false,`tags` text,`created_at` datetime,`expiration` datetime,`prefix` text,`hash` blob,CONSTRAINT `fk_pre_auth_keys_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL); + +-- API keys table +CREATE TABLE `api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`prefix` text,`hash` blob,`created_at` datetime,`expiration` datetime,`last_seen` datetime); + +-- Nodes table - using OLD schema with forced_tags (not tags) +CREATE TABLE IF NOT EXISTS "nodes" (`id` integer PRIMARY KEY AUTOINCREMENT,`machine_key` text,`node_key` text,`disco_key` text,`endpoints` text,`host_info` text,`ipv4` text,`ipv6` text,`hostname` text,`given_name` varchar(63),`user_id` integer,`register_method` text,`forced_tags` text,`auth_key_id` integer,`expiry` datetime,`last_seen` datetime,`approved_routes` text,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,CONSTRAINT `fk_nodes_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,CONSTRAINT `fk_nodes_auth_key` FOREIGN KEY (`auth_key_id`) REFERENCES `pre_auth_keys`(`id`)); + +-- Node 1: user1 owns it, has RequestTags for tag:server (user1 is authorized for this tag) +-- Expected: tag:server should be added to tags +INSERT INTO nodes VALUES(1,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e01','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605501','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57701','[]','{"RequestTags":["tag:server"]}','100.64.0.1','fd7a:115c:a1e0::1','node1','node1',1,'oidc','[]',NULL,'0001-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); + +-- Node 2: user1 owns it, has RequestTags for tag:unauthorized (user1 is NOT authorized for this tag) +-- Expected: tag:unauthorized should be rejected, tags stays empty +INSERT INTO nodes VALUES(2,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e02','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605502','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57702','[]','{"RequestTags":["tag:unauthorized"]}','100.64.0.2','fd7a:115c:a1e0::2','node2','node2',1,'oidc','[]',NULL,'0001-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); + +-- Node 3: user2 owns it, has RequestTags for tag:client (user2 is authorized) +-- Also has existing forced_tags that should be preserved +-- Expected: tag:client added, tag:existing preserved +INSERT INTO nodes VALUES(3,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e03','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605503','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57703','[]','{"RequestTags":["tag:client"]}','100.64.0.3','fd7a:115c:a1e0::3','node3','node3',2,'oidc','["tag:existing"]',NULL,'0001-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); + +-- Node 4: user1 owns it, has RequestTags for tag:server which already exists in forced_tags +-- Expected: no duplicates, tags should be ["tag:server"] +INSERT INTO nodes VALUES(4,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e04','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605504','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57704','[]','{"RequestTags":["tag:server"]}','100.64.0.4','fd7a:115c:a1e0::4','node4','node4',1,'oidc','["tag:server"]',NULL,'0001-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); + +-- Node 5: user2 owns it, no RequestTags in host_info +-- Expected: tags unchanged (empty) +INSERT INTO nodes VALUES(5,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e05','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605505','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57705','[]','{}','100.64.0.5','fd7a:115c:a1e0::5','node5','node5',2,'oidc','[]',NULL,'0001-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); + +-- Node 6: admin1 owns it, has RequestTags for tag:admin (admin1 is in group:admins which owns tag:admin) +-- Expected: tag:admin should be added via group membership +INSERT INTO nodes VALUES(6,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e06','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605506','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57706','[]','{"RequestTags":["tag:admin"]}','100.64.0.6','fd7a:115c:a1e0::6','node6','node6',3,'oidc','[]',NULL,'0001-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); + +-- Node 7: user1 owns it, has multiple RequestTags (tag:server authorized, tag:forbidden not authorized) +-- Expected: tag:server added, tag:forbidden rejected +INSERT INTO nodes VALUES(7,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e07','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605507','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57707','[]','{"RequestTags":["tag:server","tag:forbidden"]}','100.64.0.7','fd7a:115c:a1e0::7','node7','node7',1,'oidc','[]',NULL,'0001-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); + +-- Policies table with tagOwners defining who can use which tags +-- Note: Usernames in policy must contain @ (e.g., user1@example.com or just user1@) +CREATE TABLE `policies` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`data` text); +INSERT INTO policies VALUES(1,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'{ + "groups": { + "group:admins": ["admin1@example.com"] + }, + "tagOwners": { + "tag:server": ["user1@example.com"], + "tag:client": ["user1@example.com", "user2@example.com"], + "tag:admin": ["group:admins"] + }, + "acls": [ + {"action": "accept", "src": ["*"], "dst": ["*:*"]} + ] +}'); + +-- Indexes (using exact format expected by schema validation) +DELETE FROM sqlite_sequence; +INSERT INTO sqlite_sequence VALUES('users',3); +INSERT INTO sqlite_sequence VALUES('nodes',7); +INSERT INTO sqlite_sequence VALUES('policies',1); +CREATE INDEX idx_users_deleted_at ON users(deleted_at); +CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix); +CREATE INDEX idx_policies_deleted_at ON policies(deleted_at); +CREATE UNIQUE INDEX idx_provider_identifier ON users(provider_identifier) WHERE provider_identifier IS NOT NULL; +CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier); +CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''; + +COMMIT; diff --git a/hscontrol/mapper/batcher_test.go b/hscontrol/mapper/batcher_test.go index 3cbd4e2d..70d5e377 100644 --- a/hscontrol/mapper/batcher_test.go +++ b/hscontrol/mapper/batcher_test.go @@ -213,8 +213,7 @@ func setupBatcherWithTestData( // Create database and populate it with test data database, err := db.NewHeadscaleDatabase( - cfg.Database, - "", + cfg, emptyCache(), ) if err != nil { diff --git a/hscontrol/state/debug.go b/hscontrol/state/debug.go index 02d674d5..3ed1d79f 100644 --- a/hscontrol/state/debug.go +++ b/hscontrol/state/debug.go @@ -5,6 +5,7 @@ import ( "strings" "time" + hsdb "github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/routes" "github.com/juanfont/headscale/hscontrol/types" "tailscale.com/tailcfg" @@ -228,7 +229,7 @@ func (s *State) DebugPolicy() (string, error) { return p.Data, nil case types.PolicyModeFile: - pol, err := policyBytes(s.db, s.cfg) + pol, err := hsdb.PolicyBytes(s.db.DB, s.cfg) if err != nil { return "", err } diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 61dbe7b5..d1401ef0 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -8,9 +8,7 @@ import ( "context" "errors" "fmt" - "io" "net/netip" - "os" "slices" "strings" "sync" @@ -115,8 +113,7 @@ func NewState(cfg *types.Config) (*State, error) { ) db, err := hsdb.NewHeadscaleDatabase( - cfg.Database, - cfg.BaseDomain, + cfg, registrationCache, ) if err != nil { @@ -143,7 +140,7 @@ func NewState(cfg *types.Config) (*State, error) { return nil, fmt.Errorf("loading users: %w", err) } - pol, err := policyBytes(db, cfg) + pol, err := hsdb.PolicyBytes(db.DB, cfg) if err != nil { return nil, fmt.Errorf("loading policy: %w", err) } @@ -199,47 +196,6 @@ func (s *State) Close() error { return nil } -// policyBytes loads policy configuration from file or database based on the configured mode. -// Returns nil if no policy is configured, which is valid. -func policyBytes(db *hsdb.HSDatabase, cfg *types.Config) ([]byte, error) { - switch cfg.Policy.Mode { - case types.PolicyModeFile: - path := cfg.Policy.Path - - // It is fine to start headscale without a policy file. - if len(path) == 0 { - return nil, nil - } - - absPath := util.AbsolutePathFromConfigPath(path) - policyFile, err := os.Open(absPath) - if err != nil { - return nil, err - } - defer policyFile.Close() - - return io.ReadAll(policyFile) - - case types.PolicyModeDB: - p, err := db.GetPolicy() - if err != nil { - if errors.Is(err, types.ErrPolicyNotFound) { - return nil, nil - } - - return nil, err - } - - if p.Data == "" { - return nil, nil - } - - return []byte(p.Data), err - } - - return nil, fmt.Errorf("%w: %s", ErrUnsupportedPolicyMode, cfg.Policy.Mode) -} - // SetDERPMap updates the DERP relay configuration. func (s *State) SetDERPMap(dm *tailcfg.DERPMap) { s.derpMap.Store(dm) @@ -253,7 +209,7 @@ func (s *State) DERPMap() tailcfg.DERPMapView { // ReloadPolicy reloads the access control policy and triggers auto-approval if changed. // Returns true if the policy changed. func (s *State) ReloadPolicy() ([]change.Change, error) { - pol, err := policyBytes(s.db, s.cfg) + pol, err := hsdb.PolicyBytes(s.db.DB, s.cfg) if err != nil { return nil, fmt.Errorf("loading policy: %w", err) }