fix(clients): backfill missing subId on startup and guard create/update

Legacy clients (and any API consumer that POSTs to AddInboundClient
without a subId) ended up with an empty SubID, which breaks the panel's
sub-link generation. Backfill them once at startup and stop the gap at
the write path so new clients can't reintroduce it.

- util/random: add NumLower(n) — 16-char [0-9a-z] generator that matches
  the frontend's RandomUtil.randomLowerAndNum convention.
- database/db.go: new InboundClientSubIdFix seeder, modeled on
  InboundClientTgIdFix. Loops every inbound, parses settings.clients,
  fills empty/missing subId with random.NumLower(16), persists via the
  same transaction-wrapped Update("settings", …) path, then records in
  HistoryOfSeeders so it runs at most once.
- web/service/client.go: defense-in-depth in AddInboundClient and
  UpdateInboundClient — fill subId on the persisted settings map when
  the payload omits it (Update prefers the previous value before
  generating a fresh one).
- database/db_seed_test: cover empty subId, missing-key subId, and
  preserved-existing subId; assert exactly one HistoryOfSeeders row.
This commit is contained in:
MHSanaei
2026-05-28 18:20:34 +02:00
parent 72b97efa8a
commit 99df5d70a8
4 changed files with 171 additions and 1 deletions
+60 -1
View File
@@ -19,6 +19,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/config"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/crypto"
"github.com/mhsanaei/3x-ui/v3/util/random"
"github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/driver/postgres"
@@ -144,7 +145,7 @@ func runSeeders(isUsersEmpty bool) error {
}
if empty && isUsersEmpty {
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix"}
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix"}
for _, name := range seeders {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
return err
@@ -209,6 +210,12 @@ func runSeeders(isUsersEmpty bool) error {
return err
}
}
if !slices.Contains(seedersHistory, "InboundClientSubIdFix") {
if err := normalizeInboundClientSubId(); err != nil {
return err
}
}
return nil
}
@@ -268,6 +275,58 @@ func normalizeInboundClientTgId() error {
})
}
func normalizeInboundClientSubId() error {
var inbounds []model.Inbound
if err := db.Find(&inbounds).Error; err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
for _, inbound := range inbounds {
if strings.TrimSpace(inbound.Settings) == "" {
continue
}
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
log.Printf("InboundClientSubIdFix: skip inbound %d (invalid settings json): %v", inbound.Id, err)
continue
}
clients, ok := settings["clients"].([]any)
if !ok {
continue
}
mutated := false
for i, raw := range clients {
obj, ok := raw.(map[string]any)
if !ok {
continue
}
existing, _ := obj["subId"].(string)
if strings.TrimSpace(existing) != "" {
continue
}
obj["subId"] = random.NumLower(16)
clients[i] = obj
mutated = true
}
if !mutated {
continue
}
settings["clients"] = clients
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
log.Printf("InboundClientSubIdFix: skip inbound %d (marshal failed): %v", inbound.Id, err)
continue
}
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
Update("settings", string(newSettings)).Error; err != nil {
return err
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundClientSubIdFix"}).Error
})
}
func normalizeInboundClientsArray() error {
var inbounds []model.Inbound
if err := db.Find(&inbounds).Error; err != nil {