diff --git a/internal/database/db.go b/internal/database/db.go index d080cfcc4..fd8dba844 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -686,6 +686,7 @@ func externalProxyEntryToHost(inboundId, index int, ep map[string]any) *model.Ho fingerprint, _ := ep["fingerprint"].(string) ech, _ := ep["echConfigList"].(string) return &model.Host{ + GroupId: random.NumLower(16), InboundId: inboundId, SortOrder: index, Remark: remark, @@ -1180,7 +1181,7 @@ func runSeeders(isUsersEmpty bool) error { return err } - if err := seedHostGroupIds(); err != nil { + if err := backfillEmptyHostGroupIds(); err != nil { return err } @@ -1213,36 +1214,27 @@ func seedNodeInboundsAdopted() 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_ group the update/delete API cannot address, so +// re-check on every start. +func backfillEmptyHostGroupIds() error { var hosts []*model.Host if err := db.Where("group_id = '' OR group_id IS NULL").Find(&hosts).Error; err != nil { 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 - } - } - return nil - }) - if err != nil { - return err - } + if len(hosts) == 0 { + return nil } - - return db.Create(&model.HistoryOfSeeders{SeederName: "HostGroupIds"}).Error + 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 + }) } func resetIpLimitsWithoutFail2ban() error { diff --git a/internal/database/host_migration_test.go b/internal/database/host_migration_test.go index ba6825941..dc8dcd10d 100644 --- a/internal/database/host_migration_test.go +++ b/internal/database/host_migration_test.go @@ -67,6 +67,47 @@ func TestMigrate_ExternalProxyToHosts(t *testing.T) { b.Port != 80 || b.Remark != "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_. +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). diff --git a/internal/web/service/host.go b/internal/web/service/host.go index 5bbf60c97..68ab9fd72 100644 --- a/internal/web/service/host.go +++ b/internal/web/service/host.go @@ -213,11 +213,11 @@ func (s *HostService) GetHostGroup(groupId string) (*entity.HostGroup, error) { return nil, err } if len(hosts) == 0 { - return nil, common.NewError("host group not found") + return nil, common.NewError("host not found") } grouped := groupHosts(hosts) if len(grouped) == 0 { - return nil, common.NewError("host group not found") + return nil, common.NewError("host not found") } return grouped[0], nil } @@ -253,7 +253,7 @@ func (s *HostService) UpdateHostGroup(groupId string, req *entity.HostGroup) ([] return err } if count == 0 { - return common.NewError("host group not found") + return common.NewError("host not found") } if err := validateInboundsExist(tx, req.InboundIds); err != nil { return err