Przeglądaj źródła

fix(runtime): drop depleted clients by email, not by stale inbound_id

Invariant: a client whose stats row is switched off is served by no local
runtime of any inbound it is attached to. client_traffics is email-keyed,
and AddClientStat re-points its inbound_id at the last inbound attached,
yet the runtime push builder and the MTProto, TUIC and AmneziaWG desired-
instance builders looked the row up by inbound_id. On every other inbound
of a multi-inbound client the depletion filter saw nothing, so a depleted
client stayed served there whenever its settings entry still read enabled
- the state the stale settings writes left in existing databases.

All four now resolve the flag through trafficDisabledEmails, keyed by the
emails the inbound actually lists. GetXrayConfig already backfills sibling
rows by email (backfillClientStats) and is unchanged.
MHSanaei 1 dzień temu
rodzic
commit
7c84ca9689

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

@@ -25,7 +25,6 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
-	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"gorm.io/gorm"
 	"gorm.io/gorm/clause"
@@ -2076,16 +2075,16 @@ func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model
 		return built, nil
 	}
 
-	var clientStats []xray.ClientTraffic
-	if err := tx.Model(xray.ClientTraffic{}).
-		Where("inbound_id = ?", built.Id).
-		Select("email", "enable").
-		Find(&clientStats).Error; err != nil {
-		return nil, err
+	emails := make([]string, 0, len(clients))
+	for _, client := range clients {
+		if c, ok := client.(map[string]any); ok {
+			email, _ := c["email"].(string)
+			emails = append(emails, email)
+		}
 	}
-	enableMap := make(map[string]bool, len(clientStats))
-	for _, clientTraffic := range clientStats {
-		enableMap[clientTraffic.Email] = clientTraffic.Enable
+	disabled, err := trafficDisabledEmails(tx, emails)
+	if err != nil {
+		return nil, err
 	}
 
 	finalClients := make([]any, 0, len(clients))
@@ -2095,7 +2094,7 @@ func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model
 			continue
 		}
 		email, _ := c["email"].(string)
-		if enable, exists := enableMap[email]; exists && !enable {
+		if _, off := disabled[email]; off {
 			continue
 		}
 		if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {

+ 22 - 32
internal/web/service/inbound_amneziawg.go

@@ -14,7 +14,6 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
-	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 // DesiredAmneziaWGInstances derives the AmneziaWG interfaces this panel
@@ -37,47 +36,38 @@ func (s *InboundService) DesiredAmneziaWGInstances() ([]amneziawg.Instance, erro
 		return nil, nil
 	}
 
-	ids := make([]int, 0, len(inbounds))
-	for _, ib := range inbounds {
-		ids = append(ids, ib.Id)
-	}
-	var disabledRows []xray.ClientTraffic
-	err = db.Model(xray.ClientTraffic{}).
-		Where("inbound_id IN ? AND enable = ?", ids, false).
-		Select("inbound_id", "email").
-		Find(&disabledRows).Error
-	if err != nil {
-		return nil, err
-	}
-	disabled := make(map[int]map[string]struct{}, len(disabledRows))
-	for _, row := range disabledRows {
-		if disabled[row.InboundId] == nil {
-			disabled[row.InboundId] = map[string]struct{}{}
-		}
-		disabled[row.InboundId][row.Email] = struct{}{}
-	}
-
 	instances := make([]amneziawg.Instance, 0, len(inbounds))
 	for _, ib := range inbounds {
 		inst, ok := amneziawg.InstanceFromInbound(ib)
 		if !ok {
 			continue
 		}
-		if off := disabled[ib.Id]; len(off) > 0 {
-			kept := make([]amneziawg.Peer, 0, len(inst.Peers))
-			for _, p := range inst.Peers {
-				if _, skip := off[p.Email]; !skip {
-					kept = append(kept, p)
-				}
+		instances = append(instances, inst)
+	}
+	emails := make([]string, 0)
+	for _, inst := range instances {
+		for _, e := range inst.Peers {
+			emails = append(emails, e.Email)
+		}
+	}
+	disabled, err := trafficDisabledEmails(db, emails)
+	if err != nil {
+		return nil, err
+	}
+	served := instances[:0]
+	for _, inst := range instances {
+		kept := make([]amneziawg.Peer, 0, len(inst.Peers))
+		for _, e := range inst.Peers {
+			if _, off := disabled[e.Email]; !off {
+				kept = append(kept, e)
 			}
-			inst.Peers = kept
 		}
-		if len(inst.Peers) == 0 {
-			continue
+		inst.Peers = kept
+		if len(kept) > 0 {
+			served = append(served, inst)
 		}
-		instances = append(instances, inst)
 	}
-	return instances, nil
+	return served, nil
 }
 
 // applyLocalAmneziaWG pushes a single local AmneziaWG inbound's current peer

+ 18 - 0
internal/web/service/inbound_disable.go

@@ -234,3 +234,21 @@ func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID in
 	}
 	return &snapshot, &ib, nil
 }
+
+// trafficDisabledEmails reports which emails have a switched-off stats row. The
+// table is email-keyed and its inbound_id goes stale, so never filter on it.
+func trafficDisabledEmails(db *gorm.DB, emails []string) (map[string]struct{}, error) {
+	disabled := make(map[string]struct{})
+	for _, batch := range chunkStrings(uniqueNonEmptyStrings(emails), sqlInChunk) {
+		var page []string
+		if err := db.Model(xray.ClientTraffic{}).
+			Where("email IN ? AND enable = ?", batch, false).
+			Pluck("email", &page).Error; err != nil {
+			return nil, err
+		}
+		for _, e := range page {
+			disabled[e] = struct{}{}
+		}
+	}
+	return disabled, nil
+}

+ 22 - 32
internal/web/service/inbound_mtproto.go

@@ -7,7 +7,6 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
-	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 // DesiredMtprotoInstances derives the mtg sidecar configs this panel should be
@@ -32,47 +31,38 @@ func (s *InboundService) DesiredMtprotoInstances() ([]mtproto.Instance, error) {
 		return nil, nil
 	}
 
-	ids := make([]int, 0, len(inbounds))
-	for _, ib := range inbounds {
-		ids = append(ids, ib.Id)
-	}
-	var disabledRows []xray.ClientTraffic
-	err = db.Model(xray.ClientTraffic{}).
-		Where("inbound_id IN ? AND enable = ?", ids, false).
-		Select("inbound_id", "email").
-		Find(&disabledRows).Error
-	if err != nil {
-		return nil, err
-	}
-	disabled := make(map[int]map[string]struct{}, len(disabledRows))
-	for _, row := range disabledRows {
-		if disabled[row.InboundId] == nil {
-			disabled[row.InboundId] = map[string]struct{}{}
-		}
-		disabled[row.InboundId][row.Email] = struct{}{}
-	}
-
 	instances := make([]mtproto.Instance, 0, len(inbounds))
 	for _, ib := range inbounds {
 		inst, ok := mtproto.InstanceFromInbound(ib)
 		if !ok {
 			continue
 		}
-		if off := disabled[ib.Id]; len(off) > 0 {
-			kept := make([]mtproto.SecretEntry, 0, len(inst.Secrets))
-			for _, sec := range inst.Secrets {
-				if _, skip := off[sec.Name]; !skip {
-					kept = append(kept, sec)
-				}
+		instances = append(instances, inst)
+	}
+	emails := make([]string, 0)
+	for _, inst := range instances {
+		for _, e := range inst.Secrets {
+			emails = append(emails, e.Name)
+		}
+	}
+	disabled, err := trafficDisabledEmails(db, emails)
+	if err != nil {
+		return nil, err
+	}
+	served := instances[:0]
+	for _, inst := range instances {
+		kept := make([]mtproto.SecretEntry, 0, len(inst.Secrets))
+		for _, e := range inst.Secrets {
+			if _, off := disabled[e.Name]; !off {
+				kept = append(kept, e)
 			}
-			inst.Secrets = kept
 		}
-		if len(inst.Secrets) == 0 {
-			continue
+		inst.Secrets = kept
+		if len(kept) > 0 {
+			served = append(served, inst)
 		}
-		instances = append(instances, inst)
 	}
-	return instances, nil
+	return served, nil
 }
 
 // applyLocalMtproto pushes a single local mtproto inbound's current client set

+ 145 - 0
internal/web/service/inbound_runtime_depleted_filter_test.go

@@ -0,0 +1,145 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"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"
+)
+
+// seedDepletedOnSibling attaches clients d and h to inbounds a and b with d's
+// depleted traffic row pointing at b, where AddClientStat's upsert leaves it.
+func seedDepletedOnSibling(t *testing.T, proto model.Protocol, port int, settings string) (a, b *model.Inbound) {
+	t.Helper()
+	setupSettingTestDB(t)
+	db := database.GetDB()
+	for i, dst := range []**model.Inbound{&a, &b} {
+		ib := &model.Inbound{Tag: string(proto) + "-sib-" + string(rune('a'+i)), Enable: true, Port: port + i, Protocol: proto, Settings: settings}
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create inbound: %v", err)
+		}
+		clients, err := (&InboundService{}).GetClients(ib)
+		if err != nil {
+			t.Fatalf("GetClients: %v", err)
+		}
+		if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
+			t.Fatalf("SyncInbound: %v", err)
+		}
+		*dst = ib
+	}
+	rows := []xray.ClientTraffic{
+		{InboundId: b.Id, Email: "d", Enable: false, Up: 10, Total: 10},
+		{InboundId: b.Id, Email: "h", Enable: true},
+	}
+	if err := db.Create(&rows).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+	return a, b
+}
+
+func requireOnlyHealthy(t *testing.T, site string, emails []string) {
+	t.Helper()
+	if len(emails) != 1 || emails[0] != "h" {
+		t.Fatalf("%s serves %v on the sibling inbound, want only [h]: depleted d is still served", site, emails)
+	}
+}
+
+func TestRuntimeDropsDepletedClientWhoseStatsRowPointsAtSibling(t *testing.T) {
+	const vless = `{"clients":[{"email":"d","id":"11111111-1111-1111-1111-11111111111d","enable":true},` +
+		`{"email":"h","id":"11111111-1111-1111-1111-11111111111e","enable":true}],"decryption":"none"}`
+
+	t.Run("runtime push", func(t *testing.T) {
+		a, _ := seedDepletedOnSibling(t, model.VLESS, 23311, vless)
+		built, err := (&InboundService{}).buildInboundForLocalRuntime(database.GetDB(), a)
+		if err != nil {
+			t.Fatalf("buildInboundForLocalRuntime: %v", err)
+		}
+		clients, err := (&InboundService{}).GetClients(built)
+		if err != nil {
+			t.Fatalf("GetClients: %v", err)
+		}
+		var emails []string
+		for _, c := range clients {
+			emails = append(emails, c.Email)
+		}
+		requireOnlyHealthy(t, "buildInboundForLocalRuntime", emails)
+	})
+
+	t.Run("mtproto sidecar", func(t *testing.T) {
+		a, _ := seedDepletedOnSibling(t, model.MTProto, 23321,
+			`{"clients":[{"email":"d","secret":"`+mtprotoTestSecretA+`","enable":true},`+
+				`{"email":"h","secret":"`+mtprotoTestSecretB+`","enable":true}]}`)
+		instances, err := (&InboundService{}).DesiredMtprotoInstances()
+		if err != nil {
+			t.Fatalf("DesiredMtprotoInstances: %v", err)
+		}
+		for _, inst := range instances {
+			if inst.Id != a.Id {
+				continue
+			}
+			var emails []string
+			for _, sec := range inst.Secrets {
+				emails = append(emails, sec.Name)
+			}
+			requireOnlyHealthy(t, "DesiredMtprotoInstances", emails)
+			return
+		}
+		t.Fatal("sibling mtproto inbound missing from desired instances")
+	})
+
+	t.Run("tuic sidecar", func(t *testing.T) {
+		a, _ := seedDepletedOnSibling(t, model.TUIC, 23331,
+			`{"certificate":"/c.pem","private_key":"/k.pem","clients":[`+
+				`{"id":"11111111-1111-1111-1111-11111111111d","password":"pd","email":"d","enable":true},`+
+				`{"id":"11111111-1111-1111-1111-11111111111e","password":"ph","email":"h","enable":true}]}`)
+		instances, err := (&InboundService{}).DesiredTuicInstances()
+		if err != nil {
+			t.Fatalf("DesiredTuicInstances: %v", err)
+		}
+		for _, inst := range instances {
+			if inst.Id != a.Id {
+				continue
+			}
+			var emails []string
+			for _, c := range inst.Clients {
+				emails = append(emails, c.Email)
+			}
+			requireOnlyHealthy(t, "DesiredTuicInstances", emails)
+			return
+		}
+		t.Fatal("sibling tuic inbound missing from desired instances")
+	})
+
+	t.Run("amneziawg interface", func(t *testing.T) {
+		settings, err := json.Marshal(amneziawg.InboundSettings{
+			Server: &amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24},
+			Clients: []model.Client{
+				{Email: "d", Enable: true, PublicKey: "pk-d", AllowedIPs: []string{"10.8.1.2/32"}},
+				{Email: "h", Enable: true, PublicKey: "pk-h", AllowedIPs: []string{"10.8.1.3/32"}},
+			},
+		})
+		if err != nil {
+			t.Fatalf("marshal awg settings: %v", err)
+		}
+		a, _ := seedDepletedOnSibling(t, model.AmneziaWG, 23341, string(settings))
+		instances, err := (&InboundService{}).DesiredAmneziaWGInstances()
+		if err != nil {
+			t.Fatalf("DesiredAmneziaWGInstances: %v", err)
+		}
+		for _, inst := range instances {
+			if inst.Id != a.Id {
+				continue
+			}
+			var emails []string
+			for _, p := range inst.Peers {
+				emails = append(emails, p.Email)
+			}
+			requireOnlyHealthy(t, "DesiredAmneziaWGInstances", emails)
+			return
+		}
+		t.Fatal("sibling amneziawg inbound missing from desired instances")
+	})
+}

+ 22 - 32
internal/web/service/inbound_tuic.go

@@ -7,7 +7,6 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
-	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 func (s *InboundService) DesiredTuicInstances() ([]tuic.Instance, error) {
@@ -23,47 +22,38 @@ func (s *InboundService) DesiredTuicInstances() ([]tuic.Instance, error) {
 		return nil, nil
 	}
 
-	ids := make([]int, 0, len(inbounds))
-	for _, ib := range inbounds {
-		ids = append(ids, ib.Id)
-	}
-	var disabledRows []xray.ClientTraffic
-	err = db.Model(xray.ClientTraffic{}).
-		Where("inbound_id IN ? AND enable = ?", ids, false).
-		Select("inbound_id", "email").
-		Find(&disabledRows).Error
-	if err != nil {
-		return nil, err
-	}
-	disabled := make(map[int]map[string]struct{}, len(disabledRows))
-	for _, row := range disabledRows {
-		if disabled[row.InboundId] == nil {
-			disabled[row.InboundId] = map[string]struct{}{}
-		}
-		disabled[row.InboundId][row.Email] = struct{}{}
-	}
-
 	instances := make([]tuic.Instance, 0, len(inbounds))
 	for _, ib := range inbounds {
 		inst, ok := tuic.InstanceFromInbound(ib)
 		if !ok {
 			continue
 		}
-		if off := disabled[ib.Id]; len(off) > 0 {
-			kept := make([]tuic.TuicClientSettings, 0, len(inst.Clients))
-			for _, c := range inst.Clients {
-				if _, skip := off[c.Email]; !skip {
-					kept = append(kept, c)
-				}
+		instances = append(instances, inst)
+	}
+	emails := make([]string, 0)
+	for _, inst := range instances {
+		for _, e := range inst.Clients {
+			emails = append(emails, e.Email)
+		}
+	}
+	disabled, err := trafficDisabledEmails(db, emails)
+	if err != nil {
+		return nil, err
+	}
+	served := instances[:0]
+	for _, inst := range instances {
+		kept := make([]tuic.TuicClientSettings, 0, len(inst.Clients))
+		for _, e := range inst.Clients {
+			if _, off := disabled[e.Email]; !off {
+				kept = append(kept, e)
 			}
-			inst.Clients = kept
 		}
-		if len(inst.Clients) == 0 {
-			continue
+		inst.Clients = kept
+		if len(kept) > 0 {
+			served = append(served, inst)
 		}
-		instances = append(instances, inst)
 	}
-	return instances, nil
+	return served, nil
 }
 
 func (s *InboundService) applyLocalTuic(inboundId int) {