From 740f650a4d6e41994109eb9f2d8e65d2139ab12b Mon Sep 17 00:00:00 2001 From: Shourya Gautam Date: Sun, 18 Jan 2026 12:09:21 +0530 Subject: [PATCH] added validations before migrating tags --- hscontrol/db/db.go | 112 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 11 deletions(-) diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index 9c326bd8..1e456011 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -10,6 +10,7 @@ import ( "path/filepath" "slices" "strconv" + "strings" "time" "github.com/glebarez/sqlite" @@ -594,28 +595,88 @@ AND auth_key_id NOT IN ( }, { // 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 { - // Define a minimal struct to read node data + // 1. Load policy from database + var policyData string + err := tx.Raw("SELECT data FROM policies ORDER BY id DESC LIMIT 1").Scan(&policyData).Error + if err != nil || policyData == "" { + log.Info().Msg("No policy found in database, skipping RequestTags migration (tags will be validated on node reconnect)") + return nil + } + + // 2. Parse tagOwners and groups from policy + type migrationPolicy struct { + TagOwners map[string][]string `json:"tagOwners"` + Groups map[string][]string `json:"groups"` + } + var pol migrationPolicy + if err := json.Unmarshal([]byte(policyData), &pol); err != nil { + log.Warn().Err(err).Msg("Failed to parse policy JSON, skipping RequestTags migration (tags will be validated on node reconnect)") + return nil + } + + if len(pol.TagOwners) == 0 { + log.Info().Msg("No tagOwners defined in policy, skipping RequestTags migration") + return nil + } + + // Helper function to check if a user can have a tag + canUserHaveTag := func(username string, tag string) bool { + owners, exists := pol.TagOwners[tag] + if !exists { + return false // Tag not defined in policy + } + + for _, owner := range owners { + // Direct username match + if owner == username { + return true + } + + // Group expansion + if strings.HasPrefix(owner, "group:") { + if groupMembers, ok := pol.Groups[owner]; ok { + if slices.Contains(groupMembers, username) { + return true + } + } + } + } + return false + } + + // 3. Query nodes with user info type nodeRow struct { ID uint64 HostInfo string Tags string + UserID *uint64 + Username *string } - var nodes []nodeRow - err := tx.Raw("SELECT id, host_info, tags FROM nodes WHERE host_info IS NOT NULL AND host_info != '' AND host_info != '{}'").Scan(&nodes).Error + err = tx.Raw(` + SELECT n.id, n.host_info, n.tags, n.user_id, u.name as username + FROM nodes n + LEFT JOIN users u ON n.user_id = u.id + WHERE n.host_info IS NOT NULL AND n.host_info != '' AND n.host_info != '{}' + `).Scan(&nodes).Error if err != nil { return fmt.Errorf("querying nodes for RequestTags migration: %w", err) } + // 4. Process each node for _, node := range nodes { // Parse host_info JSON to extract RequestTags var hostInfo struct { RequestTags []string `json:"RequestTags"` } if err := json.Unmarshal([]byte(node.HostInfo), &hostInfo); err != nil { - // Skip nodes with invalid JSON - they may have been cleaned up log.Trace(). Uint64("node.id", node.ID). Err(err). @@ -628,6 +689,15 @@ AND auth_key_id NOT IN ( continue } + // Skip if no username (can't validate) + if node.Username == nil || *node.Username == "" { + log.Debug(). + Uint64("node.id", node.ID). + Strs("request_tags", hostInfo.RequestTags). + Msg("Skipping node without username during RequestTags migration") + continue + } + // Parse existing tags from the tags column var existingTags []string if node.Tags != "" && node.Tags != "null" { @@ -640,15 +710,33 @@ AND auth_key_id NOT IN ( } } - // Merge RequestTags with existing tags - mergedTags := existingTags + // Validate and merge RequestTags + var validatedTags []string + var rejectedTags []string for _, tag := range hostInfo.RequestTags { - if !slices.Contains(mergedTags, tag) { - mergedTags = append(mergedTags, tag) + if canUserHaveTag(*node.Username, tag) { + if !slices.Contains(existingTags, tag) { + validatedTags = append(validatedTags, tag) + } + } else { + rejectedTags = append(rejectedTags, tag) } } - // Sort and compact + // Skip if no validated tags to add + if len(validatedTags) == 0 { + if len(rejectedTags) > 0 { + log.Debug(). + Uint64("node.id", node.ID). + Str("username", *node.Username). + Strs("rejected_tags", rejectedTags). + Msg("RequestTags rejected during migration (user not authorized)") + } + continue + } + + // Merge validated tags with existing tags + mergedTags := append(existingTags, validatedTags...) slices.Sort(mergedTags) mergedTags = slices.Compact(mergedTags) @@ -665,10 +753,12 @@ AND auth_key_id NOT IN ( log.Info(). Uint64("node.id", node.ID). - Strs("request_tags", hostInfo.RequestTags). + Str("username", *node.Username). + Strs("validated_tags", validatedTags). + Strs("rejected_tags", rejectedTags). Strs("existing_tags", existingTags). Strs("merged_tags", mergedTags). - Msg("Migrated RequestTags from host_info to tags column") + Msg("Migrated validated RequestTags from host_info to tags column") } return nil