mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-29 16:39:35 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user