1
0
Эх сурвалжийг харах

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.
MHSanaei 7 цаг өмнө
parent
commit
99df5d70a8

+ 60 - 1
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 {

+ 84 - 0
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("[email protected] 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": "[email protected]",
+				"subId": "",
+			},
+			map[string]any{
+				"id":    "00000000-0000-0000-0000-000000000002",
+				"email": "[email protected]",
+			},
+			map[string]any{
+				"id":    "00000000-0000-0000-0000-000000000003",
+				"email": "[email protected]",
+				"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)
+	}
+}

+ 13 - 0
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))

+ 14 - 0
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
 		}
 	}