From 61ba5754ca50051eb72497d09f87b903ee3574e9 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 1 Jun 2026 00:43:42 +0200 Subject: [PATCH] fix(postgres): commit client traffic backfill in migration MigrationRequirements backfills missing client_traffics rows from each inbound's settings.clients, but the later MultiDomain->ExternalProxy detection query used SQLite-only json_extract and executed via .Scan. On PostgreSQL it errored, rolling back the whole transaction including the backfill, so clients had no traffic rows: client traffic was never recorded, clients showed offline, and the inbound list showed 0 clients until each inbound was edited and saved. Make the detection query dialect-aware (NULLIF(stream_settings,'')::jsonb #>> / #>) so the function runs to completion and commits on both dialects. --- web/service/inbound.go | 12 +++- web/service/inbound_migration_test.go | 91 +++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 web/service/inbound_migration_test.go diff --git a/web/service/inbound.go b/web/service/inbound.go index 0916f6d0..1d8704a2 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -3122,11 +3122,19 @@ func (s *InboundService) MigrationRequirements() { Port int StreamSettings []byte } - err = tx.Raw(`select id, port, stream_settings + externalProxyQuery := `select id, port, stream_settings from inbounds WHERE protocol in ('vmess','vless','trojan') AND json_extract(stream_settings, '$.security') = 'tls' - AND json_extract(stream_settings, '$.tlsSettings.settings.domains') IS NOT NULL`).Scan(&externalProxy).Error + AND json_extract(stream_settings, '$.tlsSettings.settings.domains') IS NOT NULL` + if database.IsPostgres() { + externalProxyQuery = `select id, port, stream_settings + from inbounds + WHERE protocol in ('vmess','vless','trojan') + AND NULLIF(stream_settings, '')::jsonb #>> '{security}' = 'tls' + AND NULLIF(stream_settings, '')::jsonb #> '{tlsSettings,settings,domains}' IS NOT NULL` + } + err = tx.Raw(externalProxyQuery).Scan(&externalProxy).Error if err != nil || len(externalProxy) == 0 { return } diff --git a/web/service/inbound_migration_test.go b/web/service/inbound_migration_test.go new file mode 100644 index 00000000..c054845d --- /dev/null +++ b/web/service/inbound_migration_test.go @@ -0,0 +1,91 @@ +package service + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/database" + "github.com/mhsanaei/3x-ui/v3/database/model" + "github.com/mhsanaei/3x-ui/v3/xray" +) + +// TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound guards the +// PostgreSQL fix where the externalProxy detection query (executed via .Scan) errored on +// json_extract and rolled back the whole transaction — including the client_traffics +// backfill at inbound.go:3093-3106, leaving clients with no traffic rows. A MultiDomain +// inbound is present so that query returns rows and the function runs to completion; both +// the backfill and the MultiDomain→ExternalProxy migration must then commit. +func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(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() }) + + db := database.GetDB() + + const backfillEmail = "needsbackfill@example.com" + const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c010" + + // Inbound A: a client present only in settings.clients, with no client_traffics row. + clientInbound := &model.Inbound{ + UserId: 1, + Tag: "a-tag", + Enable: true, + Port: 30001, + Protocol: model.VLESS, + Settings: `{"clients":[{"email":"` + backfillEmail + `","id":"` + uid + `","enable":true}]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(clientInbound).Error; err != nil { + t.Fatalf("create client inbound: %v", err) + } + + // Inbound B: a legacy MultiDomain inbound whose tag carries the 0.0.0.0: prefix. + // Its presence makes the externalProxy query return rows, so the function does not + // early-return and reaches the tag-cleanup statement. + multiDomainInbound := &model.Inbound{ + UserId: 1, + Tag: "inbound-0.0.0.0:30002", + Enable: true, + Port: 30002, + Protocol: model.VLESS, + Settings: `{"clients":[]}`, + StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`, + } + if err := db.Create(multiDomainInbound).Error; err != nil { + t.Fatalf("create multidomain inbound: %v", err) + } + + var before int64 + if err := db.Model(xray.ClientTraffic{}).Count(&before).Error; err != nil { + t.Fatalf("count client_traffics before: %v", err) + } + if before != 0 { + t.Fatalf("expected no client_traffics before migration, got %d", before) + } + + svc := InboundService{} + svc.MigrationRequirements() + + // The backfill must have committed: the settings-only client now owns a row. + // Before the fix this was rolled back whenever the externalProxy detection query + // errored (it does on Postgres via json_extract), so the MultiDomain inbound below + // is deliberately present to make that query return rows and run to completion. + var ct xray.ClientTraffic + if err := db.Model(xray.ClientTraffic{}).Where("email = ?", backfillEmail).First(&ct).Error; err != nil { + t.Fatalf("client_traffics row not backfilled for %s: %v", backfillEmail, err) + } + + // The MultiDomain→ExternalProxy migration must have committed too: the detection + // query ran (.Scan executes it) and the loop rewrote the inbound's streamSettings. + var refreshed model.Inbound + if err := db.First(&refreshed, multiDomainInbound.Id).Error; err != nil { + t.Fatalf("reload multidomain inbound: %v", err) + } + if !strings.Contains(refreshed.StreamSettings, "externalProxy") { + t.Errorf("MultiDomain migration did not commit; streamSettings = %q", refreshed.StreamSettings) + } +}