diff --git a/internal/web/job/check_client_ip_job.go b/internal/web/job/check_client_ip_job.go index e016f64f7..fb7ad89f0 100644 --- a/internal/web/job/check_client_ip_job.go +++ b/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 { clients, cached := clientsCache[d.inbound.Id] 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 } j.disconnectClientTemporarily(d.inbound, d.email, clients) @@ -702,11 +700,11 @@ func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound return nil, listErr } 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 } - for _, client := range settings["clients"] { + for _, client := range clients { if client.Email == clientEmail { return &candidates[i], nil } diff --git a/internal/web/job/check_client_ip_job_test.go b/internal/web/job/check_client_ip_job_test.go index 707689e58..7670b55b8 100644 --- a/internal/web/job/check_client_ip_job_test.go +++ b/internal/web/job/check_client_ip_job_test.go @@ -7,6 +7,9 @@ import ( "runtime" "testing" "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) 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": "alice@example.test", "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("alice@example.test") + 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) { // quiet tick: nothing observed => nothing live. everything merged // is historical. keeps the panel from wiping recent-but-idle ips. diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index a782ae85a..cafd52778 100644 --- a/internal/web/service/inbound.go +++ b/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) { - 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 diff --git a/internal/web/service/inbound_settings_clients.go b/internal/web/service/inbound_settings_clients.go new file mode 100644 index 000000000..1df211811 --- /dev/null +++ b/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 +} diff --git a/internal/web/service/inbound_settings_clients_test.go b/internal/web/service/inbound_settings_clients_test.go new file mode 100644 index 000000000..598161e9e --- /dev/null +++ b/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": "alice@example.test", "id": "11111111-1111-1111-1111-111111111111", "limitIp": 2}], + "decryption": "none", + "encryption": "none", + "fallbacks": [] + }`, + want: "alice@example.test", + }, + { + name: "hysteria scalar settings", + settings: `{ + "clients": [{"email": "bob@example.test", "password": "secret"}], + "version": 2, + "ignoreClientBandwidth": false + }`, + want: "bob@example.test", + }, + } + + 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": "alice@example.test", "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 != "alice@example.test" { + t.Fatalf("clients = %+v, want alice@example.test", clients) + } +} + +func TestSearchClientTrafficIgnoresProtocolScalarFields(t *testing.T) { + setupBulkDB(t) + db := database.GetDB() + + client := model.Client{ + Email: "alice@example.test", + 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": "alice@example.test", "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) + } +} diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index adeb61b5c..8fb041c26 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -1069,14 +1069,12 @@ func (s *InboundService) SearchClientTraffic(query string) (traffic *xray.Client 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) return nil, err } - clients := settings["clients"] for _, client := range clients { if (client.ID == query || client.Password == query) && client.Email != "" { traffic.Email = client.Email