Просмотр исходного кода

fix(clients): parse only settings.clients across protocols (#5855)

* fix(clients): parse only settings.clients across protocols

Several inbound settings readers decoded the whole settings object into map[string][]model.Client. Real protocol settings include scalar keys such as VLESS decryption and Hysteria version, so that shape can fail before callers reach settings.clients or leave them relying on decoder side effects.

Add one shared helper that extracts only the clients field through json.RawMessage, then use it from GetClients, SearchClientTraffic and the IP-limit job fallback paths. Regression tests cover VLESS and Hysteria settings with scalar protocol fields.

* fix(clients): reject empty inbound settings
n0ctal 1 день назад
Родитель
Сommit
567a4ac4fe

+ 4 - 6
internal/web/job/check_client_ip_job.go

@@ -357,9 +357,7 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
 	for _, d := range disconnects {
 	for _, d := range disconnects {
 		clients, cached := clientsCache[d.inbound.Id]
 		clients, cached := clientsCache[d.inbound.Id]
 		if !cached {
 		if !cached {
-			settings := map[string][]model.Client{}
-			_ = json.Unmarshal([]byte(d.inbound.Settings), &settings)
-			clients = settings["clients"]
+			clients, _ = service.ParseInboundSettingsClients(d.inbound.Settings)
 			clientsCache[d.inbound.Id] = clients
 			clientsCache[d.inbound.Id] = clients
 		}
 		}
 		j.disconnectClientTemporarily(d.inbound, d.email, clients)
 		j.disconnectClientTemporarily(d.inbound, d.email, clients)
@@ -702,11 +700,11 @@ func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound
 		return nil, listErr
 		return nil, listErr
 	}
 	}
 	for i := range candidates {
 	for i := range candidates {
-		settings := map[string][]model.Client{}
-		if jsonErr := json.Unmarshal([]byte(candidates[i].Settings), &settings); jsonErr != nil {
+		clients, jsonErr := service.ParseInboundSettingsClients(candidates[i].Settings)
+		if jsonErr != nil {
 			continue
 			continue
 		}
 		}
-		for _, client := range settings["clients"] {
+		for _, client := range clients {
 			if client.Email == clientEmail {
 			if client.Email == clientEmail {
 				return &candidates[i], nil
 				return &candidates[i], nil
 			}
 			}

+ 37 - 0
internal/web/job/check_client_ip_job_test.go

@@ -7,6 +7,9 @@ import (
 	"runtime"
 	"runtime"
 	"testing"
 	"testing"
 	"time"
 	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 )
 
 
 func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
 func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
@@ -191,6 +194,40 @@ func TestPartitionLiveIps_ConcurrentLiveIpsSortedAscending(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestGetInboundByEmailFallbackIgnoresProtocolScalarFields(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() })
+
+	inbound := &model.Inbound{
+		UserId:   1,
+		Tag:      "vless-limit-fallback",
+		Enable:   true,
+		Port:     43002,
+		Protocol: model.VLESS,
+		Settings: `{
+			"clients": [{"email": "[email protected]", "id": "11111111-1111-1111-1111-111111111111", "limitIp": 2}],
+			"decryption": "none",
+			"encryption": "none",
+			"fallbacks": []
+		}`,
+	}
+	if err := database.GetDB().Create(inbound).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	got, err := (&CheckClientIpJob{}).getInboundByEmail("[email protected]")
+	if err != nil {
+		t.Fatalf("getInboundByEmail: %v", err)
+	}
+	if got.Id != inbound.Id {
+		t.Fatalf("inbound id = %d, want %d", got.Id, inbound.Id)
+	}
+}
+
 func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
 func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
 	// quiet tick: nothing observed => nothing live. everything merged
 	// quiet tick: nothing observed => nothing live. everything merged
 	// is historical. keeps the panel from wiping recent-but-idle ips.
 	// is historical. keeps the panel from wiping recent-but-idle ips.

+ 1 - 11
internal/web/service/inbound.go

@@ -440,17 +440,7 @@ func (s *InboundService) GetInboundsByTrafficReset(period string) ([]*model.Inbo
 }
 }
 
 
 func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, error) {
 func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, error) {
-	settings := map[string][]model.Client{}
-	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
-	if settings == nil {
-		return nil, fmt.Errorf("setting is null")
-	}
-
-	clients := settings["clients"]
-	if clients == nil {
-		return nil, nil
-	}
-	return clients, nil
+	return ParseInboundSettingsClients(inbound.Settings)
 }
 }
 
 
 // GetClientsBySubId returns the inbound's clients with the given subscription
 // GetClientsBySubId returns the inbound's clients with the given subscription

+ 32 - 0
internal/web/service/inbound_settings_clients.go

@@ -0,0 +1,32 @@
+package service
+
+import (
+	"encoding/json"
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+)
+
+func ParseInboundSettingsClients(settings string) ([]model.Client, error) {
+	trimmed := strings.TrimSpace(settings)
+	if trimmed == "" || trimmed == "null" {
+		return nil, common.NewError("inbound settings is empty")
+	}
+
+	var payload struct {
+		Clients json.RawMessage `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
+		return nil, err
+	}
+	if len(payload.Clients) == 0 || string(payload.Clients) == "null" {
+		return nil, nil
+	}
+
+	var clients []model.Client
+	if err := json.Unmarshal(payload.Clients, &clients); err != nil {
+		return nil, err
+	}
+	return clients, nil
+}

+ 124 - 0
internal/web/service/inbound_settings_clients_test.go

@@ -0,0 +1,124 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestParseInboundSettingsClientsIgnoresProtocolScalarFields(t *testing.T) {
+	tests := []struct {
+		name     string
+		settings string
+		want     string
+	}{
+		{
+			name: "vless scalar settings",
+			settings: `{
+				"clients": [{"email": "[email protected]", "id": "11111111-1111-1111-1111-111111111111", "limitIp": 2}],
+				"decryption": "none",
+				"encryption": "none",
+				"fallbacks": []
+			}`,
+			want: "[email protected]",
+		},
+		{
+			name: "hysteria scalar settings",
+			settings: `{
+				"clients": [{"email": "[email protected]", "password": "secret"}],
+				"version": 2,
+				"ignoreClientBandwidth": false
+			}`,
+			want: "[email protected]",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			clients, err := ParseInboundSettingsClients(tt.settings)
+			if err != nil {
+				t.Fatalf("ParseInboundSettingsClients: %v", err)
+			}
+			if len(clients) != 1 || clients[0].Email != tt.want {
+				t.Fatalf("clients = %+v, want one client with email %s", clients, tt.want)
+			}
+		})
+	}
+}
+
+func TestParseInboundSettingsClientsRejectsEmptyOrNullSettings(t *testing.T) {
+	for _, settings := range []string{"", "   ", "null", " \n null \t "} {
+		t.Run(settings, func(t *testing.T) {
+			clients, err := ParseInboundSettingsClients(settings)
+			if err == nil {
+				t.Fatalf("ParseInboundSettingsClients(%q) error = nil, want error", settings)
+			}
+			if clients != nil {
+				t.Fatalf("clients = %+v, want nil", clients)
+			}
+		})
+	}
+}
+
+func TestGetClientsIgnoresProtocolScalarFields(t *testing.T) {
+	inbound := &model.Inbound{
+		Settings: `{
+			"clients": [{"email": "[email protected]", "id": "11111111-1111-1111-1111-111111111111"}],
+			"decryption": "none",
+			"encryption": "none",
+			"fallbacks": []
+		}`,
+	}
+
+	clients, err := (&InboundService{}).GetClients(inbound)
+	if err != nil {
+		t.Fatalf("GetClients: %v", err)
+	}
+	if len(clients) != 1 || clients[0].Email != "[email protected]" {
+		t.Fatalf("clients = %+v, want [email protected]", clients)
+	}
+}
+
+func TestSearchClientTrafficIgnoresProtocolScalarFields(t *testing.T) {
+	setupBulkDB(t)
+	db := database.GetDB()
+
+	client := model.Client{
+		Email:  "[email protected]",
+		ID:     "11111111-1111-1111-1111-111111111111",
+		SubID:  "sub-alice",
+		Enable: true,
+	}
+	inbound := &model.Inbound{
+		UserId:   1,
+		Tag:      "vless-scalar",
+		Enable:   true,
+		Port:     43001,
+		Protocol: model.VLESS,
+		Settings: `{
+			"clients": [{"email": "[email protected]", "id": "11111111-1111-1111-1111-111111111111", "subId": "sub-alice", "enable": true}],
+			"decryption": "none",
+			"encryption": "none",
+			"fallbacks": []
+		}`,
+	}
+	if err := db.Create(inbound).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	if err := db.Create(&model.ClientRecord{Email: client.Email, Enable: true, SubID: client.SubID}).Error; err != nil {
+		t.Fatalf("create client record: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: client.Email, Enable: true}).Error; err != nil {
+		t.Fatalf("create client traffic: %v", err)
+	}
+
+	traffic, err := (&InboundService{}).SearchClientTraffic(client.ID)
+	if err != nil {
+		t.Fatalf("SearchClientTraffic: %v", err)
+	}
+	if traffic.Email != client.Email || traffic.InboundId != inbound.Id {
+		t.Fatalf("traffic = %+v, want email %s inbound %d", traffic, client.Email, inbound.Id)
+	}
+}

+ 2 - 4
internal/web/service/inbound_traffic.go

@@ -1069,14 +1069,12 @@ func (s *InboundService) SearchClientTraffic(query string) (traffic *xray.Client
 
 
 	traffic.InboundId = inbound.Id
 	traffic.InboundId = inbound.Id
 
 
-	// Unmarshal settings to get clients
-	settings := map[string][]model.Client{}
-	if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
+	clients, err := ParseInboundSettingsClients(inbound.Settings)
+	if err != nil {
 		logger.Errorf("Error unmarshalling inbound settings for inbound ID %d: %v", inbound.Id, err)
 		logger.Errorf("Error unmarshalling inbound settings for inbound ID %d: %v", inbound.Id, err)
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	clients := settings["clients"]
 	for _, client := range clients {
 	for _, client := range clients {
 		if (client.ID == query || client.Password == query) && client.Email != "" {
 		if (client.ID == query || client.Password == query) && client.Email != "" {
 			traffic.Email = client.Email
 			traffic.Email = client.Email