mirror of
https://github.com/juanfont/headscale.git
synced 2026-01-22 18:18:00 +00:00
db: use PolicyManager for RequestTags migration
Refactor the RequestTags migration (202601121700-migrate-hostinfo-request-tags) to use PolicyManager.NodeCanHaveTag() instead of reimplementing tag validation. Changes: - NewHeadscaleDatabase now accepts *types.Config to allow migrations access to policy configuration - Add loadPolicyBytes helper to load policy from file or DB based on config - Add standalone GetPolicy(tx *gorm.DB) for use during migrations - Replace custom tag validation logic with PolicyManager Benefits: - Full HuJSON parsing support (not just JSON) - Proper group expansion via PolicyManager - Support for nested tags and autogroups - Works with both file and database policy modes - Single source of truth for tag validation Co-Authored-By: Shourya Gautam <shouryamgautam@gmail.com>
This commit is contained in:
parent
22afb2c61b
commit
4e1834adaf
9 changed files with 413 additions and 103 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
119
hscontrol/db/testdata/sqlite/request_tags_migration_test.sql
vendored
Normal file
119
hscontrol/db/testdata/sqlite/request_tags_migration_test.sql
vendored
Normal file
|
|
@ -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;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue