diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index a5b576ad7..cd91e2936 100644 --- a/internal/web/service/inbound.go +++ b/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 { diff --git a/internal/web/service/inbound_amneziawg.go b/internal/web/service/inbound_amneziawg.go index 6c2de6907..bd1454574 100644 --- a/internal/web/service/inbound_amneziawg.go +++ b/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) - } - } - inst.Peers = kept - } - if len(inst.Peers) == 0 { - continue - } instances = append(instances, inst) } - return instances, nil + 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(kept) > 0 { + served = append(served, inst) + } + } + return served, nil } // applyLocalAmneziaWG pushes a single local AmneziaWG inbound's current peer diff --git a/internal/web/service/inbound_disable.go b/internal/web/service/inbound_disable.go index a4e6bec58..06f890462 100644 --- a/internal/web/service/inbound_disable.go +++ b/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 +} diff --git a/internal/web/service/inbound_mtproto.go b/internal/web/service/inbound_mtproto.go index 592fafbdc..8fd5fc4f4 100644 --- a/internal/web/service/inbound_mtproto.go +++ b/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) - } - } - inst.Secrets = kept - } - if len(inst.Secrets) == 0 { - continue - } instances = append(instances, inst) } - return instances, nil + 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(kept) > 0 { + served = append(served, inst) + } + } + return served, nil } // applyLocalMtproto pushes a single local mtproto inbound's current client set diff --git a/internal/web/service/inbound_runtime_depleted_filter_test.go b/internal/web/service/inbound_runtime_depleted_filter_test.go new file mode 100644 index 000000000..5b022e388 --- /dev/null +++ b/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") + }) +} diff --git a/internal/web/service/inbound_tuic.go b/internal/web/service/inbound_tuic.go index 20605982a..f587e5eab 100644 --- a/internal/web/service/inbound_tuic.go +++ b/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) - } - } - inst.Clients = kept - } - if len(inst.Clients) == 0 { - continue - } instances = append(instances, inst) } - return instances, nil + 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(kept) > 0 { + served = append(served, inst) + } + } + return served, nil } func (s *InboundService) applyLocalTuic(inboundId int) {