Prechádzať zdrojové kódy

fix(sub): deduplicate settings.clients entries per inbound in subscription output (#5134)

Multi-node sync/import drift can leave the same client twice inside an
inbound's legacy settings.clients JSON while the normalized
client_inbounds table stays clean (SyncInbound dedupes the rows it
writes but never rewrites the JSON). All three subscription builders
iterated that JSON verbatim, so every duplicate entry became a
duplicate profile in the raw, Clash, and JSON output.

Filter and dedupe by email in one shared helper (link generation keys
purely on inbound + email, so same-email entries are pure duplicates
and dropping them is lossless). The clash/json services' own
inboundService copies became unused and are removed.
MHSanaei 16 hodín pred
rodič
commit
1b0dbf8e6d

+ 7 - 15
internal/sub/clash_service.go

@@ -9,15 +9,12 @@ import (
 	yaml "github.com/goccy/go-yaml"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
-	"github.com/mhsanaei/3x-ui/v3/internal/logger"
-	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 type SubClashService struct {
-	inboundService service.InboundService
-	enableRouting  bool
-	clashRules     string
-	SubService     *SubService
+	enableRouting bool
+	clashRules    string
+	SubService    *SubService
 }
 
 func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
@@ -36,19 +33,14 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 
 	seenEmails := make(map[string]struct{})
 	for _, inbound := range inbounds {
-		clients, err := s.inboundService.GetClients(inbound)
-		if err != nil {
-			logger.Error("SubClashService - GetClients: Unable to get clients from inbound")
-		}
-		if clients == nil {
+		clients := s.SubService.matchingClients(inbound, subId)
+		if len(clients) == 0 {
 			continue
 		}
 		s.SubService.projectThroughFallbackMaster(inbound)
 		for _, client := range clients {
-			if client.SubID == subId {
-				seenEmails[client.Email] = struct{}{}
-				proxies = append(proxies, s.getProxies(inbound, client, host)...)
-			}
+			seenEmails[client.Email] = struct{}{}
+			proxies = append(proxies, s.getProxies(inbound, client, host)...)
 		}
 	}
 

+ 5 - 13
internal/sub/json_service.go

@@ -8,10 +8,8 @@ import (
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
-	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
-	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 //go:embed default.json
@@ -24,8 +22,7 @@ type SubJsonService struct {
 	finalMask        string
 	mux              string
 
-	inboundService service.InboundService
-	SubService     *SubService
+	SubService *SubService
 }
 
 // NewSubJsonService creates a new JSON subscription service with the given configuration.
@@ -75,20 +72,15 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 	seenEmails := make(map[string]struct{})
 	// Prepare Inbounds
 	for _, inbound := range inbounds {
-		clients, err := s.inboundService.GetClients(inbound)
-		if err != nil {
-			logger.Error("SubJsonService - GetClients: Unable to get clients from inbound")
-		}
-		if clients == nil {
+		clients := s.SubService.matchingClients(inbound, subId)
+		if len(clients) == 0 {
 			continue
 		}
 		s.SubService.projectThroughFallbackMaster(inbound)
 
 		for _, client := range clients {
-			if client.SubID == subId {
-				seenEmails[client.Email] = struct{}{}
-				configArray = append(configArray, s.getConfig(inbound, client, host)...)
-			}
+			seenEmails[client.Email] = struct{}{}
+			configArray = append(configArray, s.getConfig(inbound, client, host)...)
 		}
 	}
 

+ 37 - 12
internal/sub/service.go

@@ -106,6 +106,36 @@ func listenIsInternalOnly(listen string) bool {
 	return isLoopbackHost(listen)
 }
 
+// matchingClients returns the inbound's clients whose SubID equals subId,
+// deduplicated by email. settings.clients can accumulate duplicate entries
+// for the same client (multi-node sync/import drift, old DBs): SyncInbound
+// dedupes the normalized client_inbounds rows on write but never rewrites
+// the legacy JSON, and the subscription builders iterate that JSON — so
+// without this guard every duplicate became a duplicate profile in the
+// output (#5134). Link generation keys purely on (inbound, email), so
+// same-email entries are pure duplicates and dropping them is lossless.
+func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []model.Client {
+	clients, err := s.inboundService.GetClients(inbound)
+	if err != nil {
+		logger.Error("SubService - GetClients: Unable to get clients from inbound")
+		return nil
+	}
+	var out []model.Client
+	seen := make(map[string]struct{}, len(clients))
+	for _, client := range clients {
+		if client.SubID != subId {
+			continue
+		}
+		key := strings.ToLower(client.Email)
+		if _, dup := seen[key]; dup {
+			continue
+		}
+		seen[key] = struct{}{}
+		out = append(out, client)
+	}
+	return out
+}
+
 // GetSubs retrieves subscription links for a given subscription ID and host.
 func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
 	s.PrepareForRequest(host)
@@ -134,23 +164,18 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
 
 	seenEmails := make(map[string]struct{})
 	for _, inbound := range inbounds {
-		clients, err := s.inboundService.GetClients(inbound)
-		if err != nil {
-			logger.Error("SubService - GetClients: Unable to get clients from inbound")
-		}
-		if clients == nil {
+		clients := s.matchingClients(inbound, subId)
+		if len(clients) == 0 {
 			continue
 		}
 		s.projectThroughFallbackMaster(inbound)
 		for _, client := range clients {
-			if client.SubID == subId {
-				if client.Enable {
-					hasEnabledClient = true
-				}
-				result = append(result, s.GetLink(inbound, client.Email))
-				emails = append(emails, client.Email)
-				seenEmails[client.Email] = struct{}{}
+			if client.Enable {
+				hasEnabledClient = true
 			}
+			result = append(result, s.GetLink(inbound, client.Email))
+			emails = append(emails, client.Email)
+			seenEmails[client.Email] = struct{}{}
 		}
 	}
 

+ 65 - 0
internal/sub/service_dedup_test.go

@@ -0,0 +1,65 @@
+package sub
+
+import (
+	"fmt"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestGetSubs_DuplicateSettingsClients_Deduped reproduces #5134: multi-node
+// sync/import drift can leave the same client twice inside an inbound's
+// legacy settings.clients JSON while the normalized client_inbounds table
+// stays clean. The subscription output must still contain one profile per
+// (inbound, client).
+func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	const subId = "sub-dup"
+	const email = "[email protected]"
+	const uuid = "f1b9265f-26a8-4b75-9be2-c64a94b15de1"
+
+	db := database.GetDB()
+	settings := fmt.Sprintf(`{"clients": [
+		{"id": %q, "email": %q, "subId": %q, "enable": true},
+		{"id": %q, "email": %q, "subId": %q, "enable": true}
+	]}`, uuid, email, subId, uuid, email, subId)
+	ib := &model.Inbound{
+		UserId:         1,
+		Tag:            "dup-in",
+		Enable:         true,
+		Port:           42001,
+		Protocol:       model.VLESS,
+		Settings:       settings,
+		StreamSettings: `{"network": "tcp", "security": "none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("seed client_inbound: %v", err)
+	}
+
+	s := NewSubService(false, "-ieo")
+	links, emails, _, _, err := s.GetSubs(subId, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1 (duplicate settings.clients entries must collapse)", len(links))
+	}
+	if len(emails) != 1 {
+		t.Fatalf("emails = %d, want 1, got %v", len(emails), emails)
+	}
+}