瀏覽代碼

fix(hosts): assign group ids to imported hosts and repair empty ones

Host rows created from a legacy streamSettings.externalProxy during
inbound import got an empty group_id, and the one-time HostGroupIds
seeder had already been gated off, so the UI rendered them under a
synthetic fallback_<id> group the update/delete API could not resolve,
failing every edit with "host group not found".

Assign a real group id in externalProxyEntryToHost at creation, and
replace the seeder with backfillEmptyHostGroupIds, an idempotent
startup repair that runs on every boot so rows from older builds and
restored backups are healed too. Also rename the leaked internal
error "host group not found" to "host not found" since groups are not
a user-facing concept.
Sanaei 8 小時之前
父節點
當前提交
8ef2eec3d1
共有 3 個文件被更改,包括 61 次插入28 次删除
  1. 17 25
      internal/database/db.go
  2. 41 0
      internal/database/host_migration_test.go
  3. 3 3
      internal/web/service/host.go

+ 17 - 25
internal/database/db.go

@@ -686,6 +686,7 @@ func externalProxyEntryToHost(inboundId, index int, ep map[string]any) *model.Ho
 	fingerprint, _ := ep["fingerprint"].(string)
 	fingerprint, _ := ep["fingerprint"].(string)
 	ech, _ := ep["echConfigList"].(string)
 	ech, _ := ep["echConfigList"].(string)
 	return &model.Host{
 	return &model.Host{
+		GroupId:              random.NumLower(16),
 		InboundId:            inboundId,
 		InboundId:            inboundId,
 		SortOrder:            index,
 		SortOrder:            index,
 		Remark:               remark,
 		Remark:               remark,
@@ -1180,7 +1181,7 @@ func runSeeders(isUsersEmpty bool) error {
 		return err
 		return err
 	}
 	}
 
 
-	if err := seedHostGroupIds(); err != nil {
+	if err := backfillEmptyHostGroupIds(); err != nil {
 		return err
 		return err
 	}
 	}
 
 
@@ -1213,36 +1214,27 @@ func seedNodeInboundsAdopted() error {
 	return db.Create(&model.HistoryOfSeeders{SeederName: "NodeInboundsAdopted"}).Error
 	return db.Create(&model.HistoryOfSeeders{SeederName: "NodeInboundsAdopted"}).Error
 }
 }
 
 
-func seedHostGroupIds() error {
-	var history []string
-	if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
-		return err
-	}
-	if slices.Contains(history, "HostGroupIds") {
-		return nil
-	}
-
+// backfillEmptyHostGroupIds is idempotent and not seeder-gated: builds that
+// predate group ids on the inbound-import path (and restored backups) can
+// re-introduce hosts rows with an empty group_id, and such rows render as a
+// synthetic fallback_<id> group the update/delete API cannot address, so
+// re-check on every start.
+func backfillEmptyHostGroupIds() error {
 	var hosts []*model.Host
 	var hosts []*model.Host
 	if err := db.Where("group_id = '' OR group_id IS NULL").Find(&hosts).Error; err != nil {
 	if err := db.Where("group_id = '' OR group_id IS NULL").Find(&hosts).Error; err != nil {
 		return err
 		return err
 	}
 	}
-
-	if len(hosts) > 0 {
-		err := db.Transaction(func(tx *gorm.DB) error {
-			for _, h := range hosts {
-				h.GroupId = random.NumLower(16)
-				if err := tx.Model(h).Update("group_id", h.GroupId).Error; err != nil {
-					return err
-				}
+	if len(hosts) == 0 {
+		return nil
+	}
+	return db.Transaction(func(tx *gorm.DB) error {
+		for _, h := range hosts {
+			if err := tx.Model(h).Update("group_id", random.NumLower(16)).Error; err != nil {
+				return err
 			}
 			}
-			return nil
-		})
-		if err != nil {
-			return err
 		}
 		}
-	}
-
-	return db.Create(&model.HistoryOfSeeders{SeederName: "HostGroupIds"}).Error
+		return nil
+	})
 }
 }
 
 
 func resetIpLimitsWithoutFail2ban() error {
 func resetIpLimitsWithoutFail2ban() error {

+ 41 - 0
internal/database/host_migration_test.go

@@ -67,6 +67,47 @@ func TestMigrate_ExternalProxyToHosts(t *testing.T) {
 		b.Port != 80 || b.Remark != "B" {
 		b.Port != 80 || b.Remark != "B" {
 		t.Fatalf("host B mapping wrong: %+v", b)
 		t.Fatalf("host B mapping wrong: %+v", b)
 	}
 	}
+	if a.GroupId == "" || b.GroupId == "" {
+		t.Fatalf("group ids must be assigned at creation: a=%q b=%q", a.GroupId, b.GroupId)
+	}
+	if a.GroupId == b.GroupId {
+		t.Fatalf("each entry must get its own group id, both = %q", a.GroupId)
+	}
+}
+
+// #1b — a hosts row that entered the DB without a group_id (older-build import
+// or restored backup) is repaired on every start, so it stays addressable by
+// the group-scoped update/delete API instead of surfacing as fallback_<id>.
+func TestBackfillEmptyHostGroupIds_RepairsLegacyRows(t *testing.T) {
+	initMigrateDB(t)
+	ib := seedInboundWithStream(t, "m1b", 5556, `{"network":"tcp","security":"none"}`)
+	legacy := &model.Host{InboundId: ib.Id, Remark: "legacy", Address: "c.cdn.com", Port: 443, Security: "tls"}
+	if err := GetDB().Create(legacy).Error; err != nil {
+		t.Fatalf("create legacy host: %v", err)
+	}
+
+	if err := backfillEmptyHostGroupIds(); err != nil {
+		t.Fatalf("backfill: %v", err)
+	}
+
+	var got model.Host
+	if err := GetDB().First(&got, legacy.Id).Error; err != nil {
+		t.Fatalf("reload host: %v", err)
+	}
+	if got.GroupId == "" {
+		t.Fatal("group_id still empty after backfill")
+	}
+
+	if err := backfillEmptyHostGroupIds(); err != nil {
+		t.Fatalf("second backfill: %v", err)
+	}
+	var again model.Host
+	if err := GetDB().First(&again, legacy.Id).Error; err != nil {
+		t.Fatalf("reload host after second run: %v", err)
+	}
+	if again.GroupId != got.GroupId {
+		t.Fatalf("second run must not touch repaired rows: %q -> %q", got.GroupId, again.GroupId)
+	}
 }
 }
 
 
 // #2 — a second run is a no-op (the HistoryOfSeeders gate).
 // #2 — a second run is a no-op (the HistoryOfSeeders gate).

+ 3 - 3
internal/web/service/host.go

@@ -213,11 +213,11 @@ func (s *HostService) GetHostGroup(groupId string) (*entity.HostGroup, error) {
 		return nil, err
 		return nil, err
 	}
 	}
 	if len(hosts) == 0 {
 	if len(hosts) == 0 {
-		return nil, common.NewError("host group not found")
+		return nil, common.NewError("host not found")
 	}
 	}
 	grouped := groupHosts(hosts)
 	grouped := groupHosts(hosts)
 	if len(grouped) == 0 {
 	if len(grouped) == 0 {
-		return nil, common.NewError("host group not found")
+		return nil, common.NewError("host not found")
 	}
 	}
 	return grouped[0], nil
 	return grouped[0], nil
 }
 }
@@ -253,7 +253,7 @@ func (s *HostService) UpdateHostGroup(groupId string, req *entity.HostGroup) ([]
 			return err
 			return err
 		}
 		}
 		if count == 0 {
 		if count == 0 {
-			return common.NewError("host group not found")
+			return common.NewError("host not found")
 		}
 		}
 		if err := validateInboundsExist(tx, req.InboundIds); err != nil {
 		if err := validateInboundsExist(tx, req.InboundIds); err != nil {
 			return err
 			return err