From 99df5d70a8ef681f20742495f57f4d8a0ae8a26b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 28 May 2026 18:20:34 +0200 Subject: [PATCH] fix(clients): backfill missing subId on startup and guard create/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- database/db.go | 61 ++++++++++++++++++++++++++++- database/db_seed_test.go | 84 ++++++++++++++++++++++++++++++++++++++++ util/random/random.go | 13 +++++++ web/service/client.go | 14 +++++++ 4 files changed, 171 insertions(+), 1 deletion(-) diff --git a/database/db.go b/database/db.go index 709a8941..547d5e32 100644 --- a/database/db.go +++ b/database/db.go @@ -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 { diff --git a/database/db_seed_test.go b/database/db_seed_test.go index d648774b..d466217f 100644 --- a/database/db_seed_test.go +++ b/database/db_seed_test.go @@ -3,6 +3,7 @@ package database import ( "encoding/json" "path/filepath" + "regexp" "testing" "github.com/mhsanaei/3x-ui/v3/database/model" @@ -69,3 +70,86 @@ func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testin t.Fatalf("alice@example.com should resolve to exactly one row, got %d", count) } } + +func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil { + t.Fatalf("InitDB failed: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + + settings, err := json.Marshal(map[string]any{ + "clients": []any{ + map[string]any{ + "id": "00000000-0000-0000-0000-000000000001", + "email": "missing-sub@example.com", + "subId": "", + }, + map[string]any{ + "id": "00000000-0000-0000-0000-000000000002", + "email": "no-sub-key@example.com", + }, + map[string]any{ + "id": "00000000-0000-0000-0000-000000000003", + "email": "has-sub@example.com", + "subId": "keep-me-1234", + }, + }, + }) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + inbound := model.Inbound{ + UserId: 1, + Port: 23456, + Protocol: model.VLESS, + Settings: string(settings), + Tag: "subid-fix-inbound", + } + if err := db.Create(&inbound).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + + if err := db.Where("seeder_name = ?", "InboundClientSubIdFix").Delete(&model.HistoryOfSeeders{}).Error; err != nil { + t.Fatalf("clear seeder history: %v", err) + } + + if err := normalizeInboundClientSubId(); err != nil { + t.Fatalf("normalizeInboundClientSubId: %v", err) + } + + var reloaded model.Inbound + if err := db.First(&reloaded, inbound.Id).Error; err != nil { + t.Fatalf("reload inbound: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal([]byte(reloaded.Settings), &parsed); err != nil { + t.Fatalf("unmarshal settings: %v", err) + } + clients, ok := parsed["clients"].([]any) + if !ok || len(clients) != 3 { + t.Fatalf("expected 3 clients, got %v", parsed["clients"]) + } + + subIdPattern := regexp.MustCompile(`^[0-9a-z]{16}$`) + for i := 0; i < 2; i++ { + obj := clients[i].(map[string]any) + sub, _ := obj["subId"].(string) + if !subIdPattern.MatchString(sub) { + t.Fatalf("client %d: expected 16-char [0-9a-z] subId, got %q", i, sub) + } + } + preserved := clients[2].(map[string]any)["subId"].(string) + if preserved != "keep-me-1234" { + t.Fatalf("expected existing subId preserved, got %q", preserved) + } + + var historyCount int64 + if err := db.Model(&model.HistoryOfSeeders{}).Where("seeder_name = ?", "InboundClientSubIdFix").Count(&historyCount).Error; err != nil { + t.Fatalf("count seeder history: %v", err) + } + if historyCount != 1 { + t.Fatalf("expected one InboundClientSubIdFix history row, got %d", historyCount) + } +} diff --git a/util/random/random.go b/util/random/random.go index a28072c0..5c049047 100644 --- a/util/random/random.go +++ b/util/random/random.go @@ -51,6 +51,19 @@ func Seq(n int) string { return string(runes) } +// NumLower generates a random string of length n containing digits and lowercase letters only. +func NumLower(n int) string { + runes := make([]rune, n) + for i := range n { + idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(numLowerSeq)))) + if err != nil { + panic("crypto/rand failed: " + err.Error()) + } + runes[i] = numLowerSeq[idx.Int64()] + } + return string(runes) +} + // Num generates a random integer between 0 and n-1. func Num(n int) int { bn := big.NewInt(int64(n)) diff --git a/web/service/client.go b/web/service/client.go index a56e1574..d8d01d4d 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -2880,6 +2880,10 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model cm["created_at"] = nowTs } cm["updated_at"] = nowTs + existingSub, _ := cm["subId"].(string) + if strings.TrimSpace(existingSub) == "" { + cm["subId"] = random.NumLower(16) + } interfaceClients[i] = cm } } @@ -3118,11 +3122,13 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo } settingsClients := oldSettings["clients"].([]any) var preservedCreated any + var preservedSubID string if clientIndex >= 0 && clientIndex < len(settingsClients) { if oldMap, ok := settingsClients[clientIndex].(map[string]any); ok { if v, ok2 := oldMap["created_at"]; ok2 { preservedCreated = v } + preservedSubID, _ = oldMap["subId"].(string) } } if len(interfaceClients) > 0 { @@ -3132,6 +3138,14 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo } newMap["created_at"] = preservedCreated newMap["updated_at"] = time.Now().Unix() * 1000 + newSub, _ := newMap["subId"].(string) + if strings.TrimSpace(newSub) == "" { + if strings.TrimSpace(preservedSubID) != "" { + newMap["subId"] = preservedSubID + } else { + newMap["subId"] = random.NumLower(16) + } + } interfaceClients[0] = newMap } }