mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-06 13:15:01 +03:00
Merge remote-tracking branch 'upstream/main' into sync-3.6.0
# Conflicts: # .github/workflows/claude-bot.yml # .github/workflows/release.yml # DockerInit.sh # frontend/package-lock.json # frontend/package.json # frontend/src/hooks/useClients.ts # frontend/src/layouts/AppSidebar.tsx # frontend/src/main.tsx # internal/config/version # internal/database/model/model.go # internal/web/service/client_wireguard.go # internal/web/service/inbound.go
This commit is contained in:
@@ -69,13 +69,13 @@ func defaultAmneziaWGClients(settingsJSON string, existing, clients []model.Clie
|
||||
c.PublicKey = pub
|
||||
}
|
||||
if len(c.AllowedIPs) == 0 {
|
||||
addr, err := allocateWireguardAddress(used, v4Base)
|
||||
addr, err := allocateWireguardAddress(used, v4Base, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allowed := []string{addr}
|
||||
if v6Base != "" {
|
||||
addr6, err := allocateWireguardAddress(used, v6Base)
|
||||
addr6, err := allocateWireguardAddress(used, v6Base, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -429,8 +429,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().Unix() * 1000
|
||||
cond := depletedCond(db)
|
||||
cond, condArgs := depletedCond(db)
|
||||
candidateEmails := make([]string, 0, len(plan))
|
||||
for email, entry := range plan {
|
||||
if entry.applyExpiry || entry.applyTotal {
|
||||
@@ -441,7 +440,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
|
||||
for _, batch := range chunkStrings(candidateEmails, sqlInChunk) {
|
||||
var rows []string
|
||||
if err := db.Model(xray.ClientTraffic{}).
|
||||
Where(cond+" AND enable = ? AND email IN ?", now, false, batch).
|
||||
Where(cond+" AND enable = ? AND email IN ?", append(append([]any{}, condArgs...), false, batch)...).
|
||||
Pluck("email", &rows).Error; err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
@@ -503,7 +502,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
|
||||
for _, batch := range chunkStrings(wasList, sqlInChunk) {
|
||||
var rows []string
|
||||
if err := db.Model(xray.ClientTraffic{}).
|
||||
Where(cond+" AND email IN ?", now, batch).
|
||||
Where(cond+" AND email IN ?", append(append([]any{}, condArgs...), batch)...).
|
||||
Pluck("email", &rows).Error; err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
@@ -836,13 +835,16 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
|
||||
successEmails := make([]string, 0, len(recordsByEmail))
|
||||
successIds := make([]int, 0, len(recordsByEmail))
|
||||
failedEmails := make([]string, 0, len(recordsByEmail))
|
||||
for email, rec := range recordsByEmail {
|
||||
if _, skipped := skippedReasons[email]; skipped {
|
||||
failedEmails = append(failedEmails, email)
|
||||
continue
|
||||
}
|
||||
successEmails = append(successEmails, email)
|
||||
successIds = append(successIds, rec.Id)
|
||||
}
|
||||
withdrawClientTombstones(failedEmails...)
|
||||
|
||||
if len(successIds) > 0 {
|
||||
// Serialize the row cleanup against the traffic poll to avoid the
|
||||
@@ -876,6 +878,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
withdrawClientTombstones(successEmails...)
|
||||
return result, needRestart, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestCreateAcrossManyInboundsUsesOneEmailSnapshot(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
const uuid = "bbbbbbbb-1111-2222-3333-555555555555"
|
||||
ids := make([]int, 0, 6)
|
||||
for i := range 6 {
|
||||
ib := mkInbound(t, 23001+i, model.VLESS, `{"clients":[]}`)
|
||||
ids = append(ids, ib.Id)
|
||||
}
|
||||
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "fan@x", ID: uuid, SubID: "sub-fan", Enable: true},
|
||||
InboundIds: ids,
|
||||
}); err != nil {
|
||||
t.Fatalf("Create across %d inbounds: %v", len(ids), err)
|
||||
}
|
||||
|
||||
if n := countClientRecords(t); n != 1 {
|
||||
t.Fatalf("client records = %d, want 1", n)
|
||||
}
|
||||
rec := lookupClientRecord(t, "fan@x")
|
||||
if rec.UUID != uuid || rec.SubID != "sub-fan" {
|
||||
t.Fatalf("record = {uuid:%q sub:%q}, want {%q sub-fan}", rec.UUID, rec.SubID, uuid)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !settingsHoldUUID(t, inboundSvc, id, uuid) {
|
||||
t.Fatalf("inbound %d settings missing the client", id)
|
||||
}
|
||||
}
|
||||
|
||||
linked, err := svc.GetInboundIdsForRecord(rec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInboundIdsForRecord: %v", err)
|
||||
}
|
||||
if len(linked) != len(ids) {
|
||||
t.Fatalf("linked inbounds = %d, want %d", len(linked), len(ids))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachAcrossManyInboundsUsesOneEmailSnapshot(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
first := mkInbound(t, 23101, model.VLESS, `{"clients":[]}`)
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "att@x", ID: "cccccccc-1111-2222-3333-666666666666", SubID: "sub-att", Enable: true},
|
||||
InboundIds: []int{first.Id},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed Create: %v", err)
|
||||
}
|
||||
rec := lookupClientRecord(t, "att@x")
|
||||
|
||||
ids := []int{first.Id}
|
||||
for i := range 4 {
|
||||
ib := mkInbound(t, 23102+i, model.VLESS, `{"clients":[]}`)
|
||||
ids = append(ids, ib.Id)
|
||||
}
|
||||
|
||||
if _, err := svc.Attach(inboundSvc, rec.Id, ids); err != nil {
|
||||
t.Fatalf("Attach across %d inbounds: %v", len(ids), err)
|
||||
}
|
||||
|
||||
if n := countClientRecords(t); n != 1 {
|
||||
t.Fatalf("client records after attach = %d, want 1", n)
|
||||
}
|
||||
linked, err := svc.GetInboundIdsForRecord(rec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInboundIdsForRecord: %v", err)
|
||||
}
|
||||
if len(linked) != len(ids) {
|
||||
t.Fatalf("linked inbounds = %d, want %d", len(linked), len(ids))
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -21,7 +22,7 @@ import (
|
||||
|
||||
func hasForbiddenClientChar(s string) bool {
|
||||
for _, r := range s {
|
||||
if r == '/' || r == '\\' || r == ' ' || r < 0x20 || r == 0x7f {
|
||||
if r == '/' || r == '\\' || r < 0x20 || r == 0x7f || unicode.IsSpace(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -110,6 +111,11 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
|
||||
}
|
||||
}
|
||||
|
||||
emailSubIDs, sidErr := inboundSvc.getAllEmailSubIDs()
|
||||
if sidErr != nil {
|
||||
return false, sidErr
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range payload.InboundIds {
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
@@ -123,10 +129,10 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
|
||||
if mErr != nil {
|
||||
return needRestart, mErr
|
||||
}
|
||||
nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
|
||||
nr, addErr := s.addInboundClient(inboundSvc, &model.Inbound{
|
||||
Id: ibId,
|
||||
Settings: string(settingsPayload),
|
||||
})
|
||||
}, emailSubIDs)
|
||||
if addErr != nil {
|
||||
return needRestart, addErr
|
||||
}
|
||||
@@ -375,7 +381,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||
}
|
||||
}
|
||||
|
||||
if updated.SubID != "" {
|
||||
if updated.SubID != existing.SubID {
|
||||
var subCollision int64
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||
Where("sub_id = ? AND id <> ?", updated.SubID, id).
|
||||
@@ -518,6 +524,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
|
||||
inboundIds, err := s.GetInboundIdsForRecord(id)
|
||||
if err != nil {
|
||||
withdrawClientTombstones(existing.Email)
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -555,7 +562,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
}
|
||||
// A failed inbound still holds the client in its settings JSON: keep the
|
||||
// record so the next delete retries exactly the leftovers, and report it.
|
||||
// The tombstone lifts with it, or the next node merge finishes the deletion.
|
||||
if len(delErrs) > 0 {
|
||||
withdrawClientTombstones(existing.Email)
|
||||
return needRestart, errors.Join(delErrs...)
|
||||
}
|
||||
|
||||
@@ -588,6 +597,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
}
|
||||
return tx.Delete(&model.ClientRecord{}, id).Error
|
||||
}); err != nil {
|
||||
withdrawClientTombstones(existing.Email)
|
||||
return needRestart, err
|
||||
}
|
||||
return needRestart, nil
|
||||
@@ -615,6 +625,11 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
|
||||
clientWire.Flow = flow
|
||||
clientWire.UpdatedAt = time.Now().UnixMilli()
|
||||
|
||||
emailSubIDs, sidErr := inboundSvc.getAllEmailSubIDs()
|
||||
if sidErr != nil {
|
||||
return false, sidErr
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range inboundIds {
|
||||
if _, attached := have[ibId]; attached {
|
||||
@@ -632,10 +647,10 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
|
||||
if mErr != nil {
|
||||
return needRestart, mErr
|
||||
}
|
||||
nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
|
||||
nr, addErr := s.addInboundClient(inboundSvc, &model.Inbound{
|
||||
Id: ibId,
|
||||
Settings: string(settingsPayload),
|
||||
})
|
||||
}, emailSubIDs)
|
||||
if addErr != nil {
|
||||
return needRestart, addErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Delete tombstones up front and keeps the record when an inbound fails. A
|
||||
// surviving tombstone lets the next node merge finish the refused deletion.
|
||||
func TestFailedDeleteWithdrawsTombstone(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
db := database.GetDB()
|
||||
|
||||
const email = "retry@x"
|
||||
broken := mkInbound(t, 30401, model.VLESS, `{"clients": [ THIS IS NOT JSON`)
|
||||
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "33333333-3333-3333-3333-333333333333"}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("create client record: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: broken.Id}).Error; err != nil {
|
||||
t.Fatalf("attach client: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { withdrawClientTombstones(email) })
|
||||
|
||||
if _, err := svc.Delete(inboundSvc, rec.Id, false); err == nil {
|
||||
t.Fatal("setup: delete was expected to fail on the unparseable inbound settings")
|
||||
}
|
||||
|
||||
var surviving int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil {
|
||||
t.Fatalf("count clients: %v", err)
|
||||
}
|
||||
if surviving != 1 {
|
||||
t.Fatalf("failed delete must keep the record for a retry, got %d rows", surviving)
|
||||
}
|
||||
if isClientEmailTombstoned(email) {
|
||||
t.Fatal("delete kept the record but left a live tombstone: the next node sync would finish the deletion it refused")
|
||||
}
|
||||
}
|
||||
@@ -495,7 +495,7 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
|
||||
}
|
||||
cipher := ""
|
||||
if oldInbound.Protocol == "shadowsocks" {
|
||||
cipher = oldSettings["method"].(string)
|
||||
cipher, _ = oldSettings["method"].(string)
|
||||
}
|
||||
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
||||
"email": client.Email,
|
||||
@@ -899,7 +899,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
if clients[0].Enable {
|
||||
cipher := ""
|
||||
if oldInbound.Protocol == "shadowsocks" {
|
||||
cipher = oldSettings["method"].(string)
|
||||
cipher, _ = oldSettings["method"].(string)
|
||||
}
|
||||
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
||||
"email": clients[0].Email,
|
||||
|
||||
@@ -115,6 +115,9 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
|
||||
}
|
||||
|
||||
incoming := clients[i].ToRecord()
|
||||
// ToRecord copies the raw email; store the trimmed key this function
|
||||
// looks up by, or a padded email is inserted and never found again.
|
||||
incoming.Email = email
|
||||
row, ok := existing[email]
|
||||
if !ok {
|
||||
if _, dup := pending[email]; !dup {
|
||||
|
||||
@@ -105,6 +105,19 @@ func tombstoneClientEmail(email string) {
|
||||
}
|
||||
}
|
||||
|
||||
func withdrawClientTombstones(emails ...string) {
|
||||
if len(emails) == 0 {
|
||||
return
|
||||
}
|
||||
recentlyDeletedMu.Lock()
|
||||
defer recentlyDeletedMu.Unlock()
|
||||
for _, email := range emails {
|
||||
if email != "" {
|
||||
delete(recentlyDeleted, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tombstoneClientEmails(emails []string) {
|
||||
if len(emails) == 0 {
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
@@ -103,6 +104,15 @@ func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int,
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) GetRecordsByTgID(tgId int64) ([]*model.ClientRecord, error) {
|
||||
if tgId <= 0 {
|
||||
return nil, errors.New("tg_id must be a positive integer")
|
||||
}
|
||||
var rows []*model.ClientRecord
|
||||
err := database.GetDB().Where("tg_id = ?", tgId).Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
|
||||
row := &model.ClientRecord{}
|
||||
if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestGetRecordsByTgID(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
db := database.GetDB()
|
||||
|
||||
records := []model.ClientRecord{
|
||||
{Email: "alice@x", TgID: 100, SubID: "sa"},
|
||||
{Email: "bob@x", TgID: 100, SubID: "sb"},
|
||||
{Email: "carol@x", TgID: 200, SubID: "sc"},
|
||||
{Email: "dave@x", TgID: 0, SubID: "sd"},
|
||||
}
|
||||
for _, r := range records {
|
||||
if err := db.Create(&r).Error; err != nil {
|
||||
t.Fatalf("create record %q: %v", r.Email, err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("multiple clients share tgId", func(t *testing.T) {
|
||||
got, err := svc.GetRecordsByTgID(100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordsByTgID(100): %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 records, got %d", len(got))
|
||||
}
|
||||
emails := make(map[string]bool)
|
||||
for _, r := range got {
|
||||
emails[r.Email] = true
|
||||
}
|
||||
if !emails["alice@x"] || !emails["bob@x"] {
|
||||
t.Fatalf("expected alice@x and bob@x, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single client by tgId", func(t *testing.T) {
|
||||
got, err := svc.GetRecordsByTgID(200)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordsByTgID(200): %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(got))
|
||||
}
|
||||
if got[0].Email != "carol@x" {
|
||||
t.Fatalf("expected carol@x, got %s", got[0].Email)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tgId zero rejected as sentinel", func(t *testing.T) {
|
||||
_, err := svc.GetRecordsByTgID(0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for tgId=0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative tgId rejected", func(t *testing.T) {
|
||||
_, err := svc.GetRecordsByTgID(-5)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for tgId=-5")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent tgId returns empty", func(t *testing.T) {
|
||||
got, err := svc.GetRecordsByTgID(999)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordsByTgID(999): %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected 0 records, got %d", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ClientSlim is the row-shape used by the clients page. It drops fields the
|
||||
@@ -74,33 +77,262 @@ type ClientPageResponse struct {
|
||||
|
||||
// ClientsSummary collects per-bucket counts plus the matching email lists so
|
||||
// the clients page can render the dashboard stat cards and their hover
|
||||
// popovers without shipping the full client array.
|
||||
// popovers without shipping the full client array. The counters are exact;
|
||||
// the lists stop at clientSummaryEmailCap entries and only back the popovers.
|
||||
type ClientsSummary struct {
|
||||
Total int `json:"total"`
|
||||
Active int `json:"active"`
|
||||
Online []string `json:"online"`
|
||||
Depleted []string `json:"depleted"`
|
||||
Expiring []string `json:"expiring"`
|
||||
Deactive []string `json:"deactive"`
|
||||
Total int `json:"total"`
|
||||
Active int `json:"active"`
|
||||
OnlineCount int `json:"onlineCount"`
|
||||
DepletedCount int `json:"depletedCount"`
|
||||
ExpiringCount int `json:"expiringCount"`
|
||||
DeactiveCount int `json:"deactiveCount"`
|
||||
Online []string `json:"online"`
|
||||
Depleted []string `json:"depleted"`
|
||||
Expiring []string `json:"expiring"`
|
||||
Deactive []string `json:"deactive"`
|
||||
}
|
||||
|
||||
const (
|
||||
clientPageDefaultSize = 25
|
||||
clientPageMaxSize = 200
|
||||
// clientSummaryEmailCap bounds each bucket's email list. Shipping every
|
||||
// matching email made the response — and the Zod validation the page runs
|
||||
// over it — grow with the client count on a request that repeats every 5s,
|
||||
// and left the hover popover rendering thousands of rows.
|
||||
clientSummaryEmailCap = 200
|
||||
// sqlNeverSentinel sorts "never expires" / "unlimited quota" clients last,
|
||||
// matching the sentinel the in-memory comparator used.
|
||||
sqlNeverSentinel = "4611686018427387903"
|
||||
// sqlClientEnabled tolerates a NULL enable column, which GORM scans as
|
||||
// false: without the COALESCE such a row would match neither the enabled
|
||||
// nor the disabled branch of any predicate.
|
||||
sqlClientEnabled = "COALESCE(c.enable, FALSE)"
|
||||
)
|
||||
|
||||
// ListPaged loads every client (with traffic + attachments) into memory,
|
||||
// applies the requested filter / search / protocol predicates, sorts, and
|
||||
// returns the requested page along with total and filtered counts. The DB
|
||||
// query itself is unchanged from List(); the win is that the response
|
||||
// only carries 25-ish slim rows over the wire instead of all 2000 full
|
||||
// records, which on real panels was the dominant cost.
|
||||
func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
|
||||
all, err := s.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
const clientSearchCond = `(LOWER(c.email) LIKE ? ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.sub_id, '')) LIKE ? ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.comment, '')) LIKE ? ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.uuid, '')) LIKE ? ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.password, '')) LIKE ? ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.auth, '')) LIKE ? ESCAPE '\'
|
||||
OR (COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\'))`
|
||||
|
||||
// clientQuery builds the statements behind the clients page: a clients row
|
||||
// joined to its traffic counters, plus the expressions every bucket predicate
|
||||
// shares. Filtering, sorting, paging and the summary all run in the database.
|
||||
// Loading every client (with attachments and traffic) into Go and doing it in
|
||||
// memory cost ~200ms per request at 20k clients on a page that polls every
|
||||
// 5 seconds, which is what made the table feel stuck on large panels.
|
||||
type clientQuery struct {
|
||||
db *gorm.DB
|
||||
joins []clientQueryJoin
|
||||
usedExpr string
|
||||
nowMs int64
|
||||
expireDiffMs int64
|
||||
trafficDiffBytes int64
|
||||
}
|
||||
|
||||
type clientQueryJoin struct {
|
||||
sql string
|
||||
args []any
|
||||
}
|
||||
|
||||
func newClientQuery(db *gorm.DB, nowMs, expireDiffMs, trafficDiffBytes int64) clientQuery {
|
||||
q := clientQuery{
|
||||
db: db,
|
||||
nowMs: nowMs,
|
||||
expireDiffMs: expireDiffMs,
|
||||
trafficDiffBytes: trafficDiffBytes,
|
||||
joins: []clientQueryJoin{{sql: "LEFT JOIN client_traffics ct ON ct.email = c.email"}},
|
||||
usedExpr: "(COALESCE(ct.up, 0) + COALESCE(ct.down, 0))",
|
||||
}
|
||||
total := len(all)
|
||||
freshSince := globalTrafficFreshSince()
|
||||
var probe int64
|
||||
err := db.Model(&model.ClientGlobalTraffic{}).
|
||||
Where("updated_at >= ?", freshSince).
|
||||
Limit(1).Count(&probe).Error
|
||||
if err != nil || probe == 0 {
|
||||
return q
|
||||
}
|
||||
// A master still pushes cross-panel usage here, so the predicates have to
|
||||
// see the same raised counters overlayGlobalTraffic applies on read.
|
||||
q.joins = append(q.joins, clientQueryJoin{
|
||||
sql: "LEFT JOIN (SELECT email, MAX(up) AS up, MAX(down) AS down FROM client_global_traffics" +
|
||||
" WHERE updated_at >= ? GROUP BY email) g ON g.email = c.email",
|
||||
args: []any{freshSince},
|
||||
})
|
||||
q.usedExpr = "(CASE WHEN COALESCE(g.up, 0) > COALESCE(ct.up, 0) THEN COALESCE(g.up, 0) ELSE COALESCE(ct.up, 0) END" +
|
||||
" + CASE WHEN COALESCE(g.down, 0) > COALESCE(ct.down, 0) THEN COALESCE(g.down, 0) ELSE COALESCE(ct.down, 0) END)"
|
||||
return q
|
||||
}
|
||||
|
||||
func (q clientQuery) from() *gorm.DB {
|
||||
tx := q.db.Table("clients AS c")
|
||||
for _, j := range q.joins {
|
||||
tx = tx.Joins(j.sql, j.args...)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func (q clientQuery) depletedExpr() string {
|
||||
return "((c.total_gb > 0 AND " + q.usedExpr + " >= c.total_gb)" +
|
||||
" OR (c.expiry_time > 0 AND c.expiry_time <= " + sqlInt(q.nowMs) + "))"
|
||||
}
|
||||
|
||||
func (q clientQuery) nearDepletionExpr() string {
|
||||
return "((c.expiry_time > 0 AND c.expiry_time - " + sqlInt(q.nowMs) + " < " + sqlInt(q.expireDiffMs) + ")" +
|
||||
" OR (c.total_gb > 0 AND c.total_gb - " + q.usedExpr + " < " + sqlInt(q.trafficDiffBytes) + "))"
|
||||
}
|
||||
|
||||
func (q clientQuery) expiringExpr() string {
|
||||
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND " + q.nearDepletionExpr() + ")"
|
||||
}
|
||||
|
||||
func (q clientQuery) activeExpr() string {
|
||||
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
|
||||
}
|
||||
|
||||
// summaryDeactiveExpr is narrower than the "deactive" bucket filter: a disabled
|
||||
// client that also ran out counts once, under depleted, so the stat cards add
|
||||
// up to the client total.
|
||||
func (q clientQuery) summaryDeactiveExpr() string {
|
||||
return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
|
||||
}
|
||||
|
||||
// applyParams narrows tx by every predicate the clients page sends. Matching is
|
||||
// OR within a field and AND across fields, mirroring the query-param contract.
|
||||
// The second return says whether anything narrowed the set, so an unfiltered
|
||||
// request can reuse the total count instead of scanning for it again.
|
||||
func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines []string) (*gorm.DB, bool) {
|
||||
narrowed := false
|
||||
where := func(cond string, args ...any) {
|
||||
narrowed = true
|
||||
tx = tx.Where(cond, args...)
|
||||
}
|
||||
|
||||
if needle := strings.ToLower(strings.TrimSpace(params.Search)); needle != "" {
|
||||
pattern := "%" + escapeLikeLiteral(needle) + "%"
|
||||
where(clientSearchCond, pattern, pattern, pattern, pattern, pattern, pattern, pattern)
|
||||
}
|
||||
if protocols := parseCSVStrings(params.Protocol); len(protocols) > 0 {
|
||||
where("EXISTS (SELECT 1 FROM client_inbounds ci JOIN inbounds ib ON ib.id = ci.inbound_id"+
|
||||
" WHERE ci.client_id = c.id AND LOWER(ib.protocol) IN ?)", protocols)
|
||||
}
|
||||
if inboundIds := parseCSVInts(params.Inbound); len(inboundIds) > 0 {
|
||||
where("EXISTS (SELECT 1 FROM client_inbounds ci WHERE ci.client_id = c.id AND ci.inbound_id IN ?)", inboundIds)
|
||||
}
|
||||
if buckets := parseCSVStrings(params.Filter); len(buckets) > 0 {
|
||||
cond, args := q.bucketCond(buckets, onlines)
|
||||
where(cond, args...)
|
||||
}
|
||||
if params.ExpiryFrom > 0 || params.ExpiryTo > 0 {
|
||||
// 0 means "never expires" and a negative value is the delayed-start
|
||||
// sentinel; both sit outside any bounded range.
|
||||
where("c.expiry_time > 0")
|
||||
if params.ExpiryFrom > 0 {
|
||||
where("c.expiry_time >= ?", params.ExpiryFrom)
|
||||
}
|
||||
if params.ExpiryTo > 0 {
|
||||
where("c.expiry_time <= ?", params.ExpiryTo)
|
||||
}
|
||||
}
|
||||
if params.UsageFrom > 0 {
|
||||
where(q.usedExpr+" >= ?", params.UsageFrom)
|
||||
}
|
||||
if params.UsageTo > 0 {
|
||||
where(q.usedExpr+" <= ?", params.UsageTo)
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) {
|
||||
case "on":
|
||||
where("COALESCE(c.reset, 0) > 0")
|
||||
case "off":
|
||||
where("COALESCE(c.reset, 0) <= 0")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(params.HasTgID)) {
|
||||
case "yes":
|
||||
where("COALESCE(c.tg_id, 0) <> 0")
|
||||
case "no":
|
||||
where("COALESCE(c.tg_id, 0) = 0")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(params.HasComment)) {
|
||||
case "yes":
|
||||
where("TRIM(COALESCE(c.comment, '')) <> ''")
|
||||
case "no":
|
||||
where("TRIM(COALESCE(c.comment, '')) = ''")
|
||||
}
|
||||
if groups := parseCSVStrings(params.Group); len(groups) > 0 {
|
||||
where("LOWER(TRIM(COALESCE(c.group_name, ''))) IN ?", groups)
|
||||
}
|
||||
return tx, narrowed
|
||||
}
|
||||
|
||||
func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) {
|
||||
conds := make([]string, 0, len(buckets))
|
||||
args := make([]any, 0, len(buckets))
|
||||
for _, b := range buckets {
|
||||
switch b {
|
||||
case "active":
|
||||
conds = append(conds, "("+sqlClientEnabled+" AND NOT "+q.depletedExpr()+")")
|
||||
case "deactive":
|
||||
conds = append(conds, "(NOT "+sqlClientEnabled+")")
|
||||
case "depleted":
|
||||
conds = append(conds, q.depletedExpr())
|
||||
case "expiring":
|
||||
conds = append(conds, q.expiringExpr())
|
||||
case "online":
|
||||
cond, inArgs := emailInCond("c.email", onlines)
|
||||
conds = append(conds, "("+sqlClientEnabled+" AND "+cond+")")
|
||||
args = append(args, inArgs...)
|
||||
default:
|
||||
// An unrecognised bucket name matched every client before the
|
||||
// predicates moved into SQL; keep that so a stale saved filter
|
||||
// cannot silently empty the table.
|
||||
conds = append(conds, "(1 = 1)")
|
||||
}
|
||||
}
|
||||
return "(" + strings.Join(conds, " OR ") + ")", args
|
||||
}
|
||||
|
||||
func (q clientQuery) applyOrder(tx *gorm.DB, sortKey, order string) *gorm.DB {
|
||||
dir := " ASC"
|
||||
if order == "descend" {
|
||||
dir = " DESC"
|
||||
}
|
||||
// createdAt / updatedAt / lastOnline broke ties on the client id inside the
|
||||
// comparator, so reversing the sort reversed the tiebreak with it. The
|
||||
// other keys leaned on a stable sort over an id-ordered slice instead.
|
||||
tieDir := " ASC"
|
||||
var expr string
|
||||
switch sortKey {
|
||||
case "enable":
|
||||
expr = sqlClientEnabled
|
||||
case "email":
|
||||
expr = "LOWER(c.email)"
|
||||
case "inboundIds":
|
||||
expr = "(SELECT COUNT(*) FROM client_inbounds ci WHERE ci.client_id = c.id)"
|
||||
case "traffic":
|
||||
expr = q.usedExpr
|
||||
case "remaining":
|
||||
expr = "CASE WHEN c.total_gb > 0 THEN c.total_gb - " + q.usedExpr + " ELSE " + sqlNeverSentinel + " END"
|
||||
case "expiryTime":
|
||||
expr = "CASE WHEN c.expiry_time > 0 THEN c.expiry_time ELSE " + sqlNeverSentinel + " END"
|
||||
case "createdAt":
|
||||
expr, tieDir = "c.created_at", dir
|
||||
case "updatedAt":
|
||||
expr, tieDir = "c.updated_at", dir
|
||||
case "lastOnline":
|
||||
expr, tieDir = "COALESCE(ct.last_online, 0)", dir
|
||||
default:
|
||||
return tx.Order("c.id ASC")
|
||||
}
|
||||
return tx.Order(expr + dir + ", c.id" + tieDir)
|
||||
}
|
||||
|
||||
// ListPaged returns one page of clients together with the counts the clients
|
||||
// page header needs. Every predicate runs in SQL, so the cost tracks the page
|
||||
// size rather than the number of clients on the panel.
|
||||
func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
pageSize := params.PageSize
|
||||
if pageSize <= 0 {
|
||||
@@ -114,27 +346,6 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
||||
page = 1
|
||||
}
|
||||
|
||||
protocols := parseCSVStrings(params.Protocol)
|
||||
inboundIDs := parseCSVInts(params.Inbound)
|
||||
buckets := parseCSVStrings(params.Filter)
|
||||
|
||||
var protocolByInbound map[int]string
|
||||
if len(protocols) > 0 {
|
||||
inbounds, err := inboundSvc.GetAllInbounds()
|
||||
if err == nil {
|
||||
protocolByInbound = make(map[int]string, len(inbounds))
|
||||
for _, ib := range inbounds {
|
||||
protocolByInbound[ib.Id] = string(ib.Protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onlines := inboundSvc.GetOnlineClients()
|
||||
onlineSet := make(map[string]struct{}, len(onlines))
|
||||
for _, e := range onlines {
|
||||
onlineSet[e] = struct{}{}
|
||||
}
|
||||
|
||||
var expireDiffMs, trafficDiffBytes int64
|
||||
if settingSvc != nil {
|
||||
if v, err := settingSvc.GetExpireDiff(); err == nil {
|
||||
@@ -145,77 +356,44 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
||||
}
|
||||
}
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
summary := buildClientsSummary(all, onlineSet, nowMs, expireDiffMs, trafficDiffBytes)
|
||||
onlines := inboundSvc.GetOnlineClients()
|
||||
q := newClientQuery(db, time.Now().UnixMilli(), expireDiffMs, trafficDiffBytes)
|
||||
|
||||
needle := strings.ToLower(strings.TrimSpace(params.Search))
|
||||
|
||||
filtered := make([]ClientWithAttachments, 0, len(all))
|
||||
for _, c := range all {
|
||||
if needle != "" && !clientMatchesSearch(c, needle) {
|
||||
continue
|
||||
}
|
||||
if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) {
|
||||
continue
|
||||
}
|
||||
if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) {
|
||||
continue
|
||||
}
|
||||
if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
||||
continue
|
||||
}
|
||||
if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) {
|
||||
continue
|
||||
}
|
||||
if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) {
|
||||
continue
|
||||
}
|
||||
if !clientMatchesAutoRenew(c, params.AutoRenew) {
|
||||
continue
|
||||
}
|
||||
if !clientMatchesHasTgID(c, params.HasTgID) {
|
||||
continue
|
||||
}
|
||||
if !clientMatchesHasComment(c, params.HasComment) {
|
||||
continue
|
||||
}
|
||||
if !clientMatchesAnyGroup(c, params.Group) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, c)
|
||||
var total int64
|
||||
if err := db.Model(&model.ClientRecord{}).Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sortClients(filtered, params.Sort, params.Order)
|
||||
|
||||
filteredCount := len(filtered)
|
||||
start := (page - 1) * pageSize
|
||||
end := start + pageSize
|
||||
if start > filteredCount {
|
||||
start = filteredCount
|
||||
}
|
||||
if end > filteredCount {
|
||||
end = filteredCount
|
||||
}
|
||||
pageRows := filtered[start:end]
|
||||
|
||||
items := make([]ClientSlim, 0, len(pageRows))
|
||||
for _, c := range pageRows {
|
||||
items = append(items, toClientSlim(c))
|
||||
summary, err := q.summary(onlines, int(total))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groupRows, gErr := s.ListGroups()
|
||||
if gErr != nil {
|
||||
return nil, gErr
|
||||
filtered := total
|
||||
if scoped, narrowed := q.applyParams(q.from(), params, onlines); narrowed {
|
||||
if err := scoped.Count(&filtered).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
groups := make([]string, 0, len(groupRows))
|
||||
for _, g := range groupRows {
|
||||
groups = append(groups, g.Name)
|
||||
|
||||
items := []ClientSlim{}
|
||||
offset := (page - 1) * pageSize
|
||||
if int64(offset) < filtered {
|
||||
items, err = q.pageRows(params, onlines, offset, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
groups, err := s.listGroupNames()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ClientPageResponse{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Filtered: filteredCount,
|
||||
Total: int(total),
|
||||
Filtered: int(filtered),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Summary: summary,
|
||||
@@ -223,77 +401,229 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary {
|
||||
// pageRows resolves the requested page to client ids, then loads the records,
|
||||
// attachments and traffic for those ids only. A page never exceeds
|
||||
// clientPageMaxSize rows, which stays under sqlInChunk, so the follow-up IN
|
||||
// lists need no chunking.
|
||||
func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset, limit int) ([]ClientSlim, error) {
|
||||
tx, _ := q.applyParams(q.from(), params, onlines)
|
||||
var ids []int
|
||||
if err := q.applyOrder(tx, params.Sort, params.Order).
|
||||
Offset(offset).Limit(limit).
|
||||
Pluck("c.id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return []ClientSlim{}, nil
|
||||
}
|
||||
|
||||
var records []model.ClientRecord
|
||||
if err := q.db.Where("id IN ?", ids).Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byId := make(map[int]*model.ClientRecord, len(records))
|
||||
emails := make([]string, 0, len(records))
|
||||
for i := range records {
|
||||
byId[records[i].Id] = &records[i]
|
||||
if records[i].Email != "" {
|
||||
emails = append(emails, records[i].Email)
|
||||
}
|
||||
}
|
||||
|
||||
var links []model.ClientInbound
|
||||
if err := q.db.Where("client_id IN ?", ids).Order("inbound_id ASC").Find(&links).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachments := make(map[int][]int, len(ids))
|
||||
for _, l := range links {
|
||||
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
|
||||
}
|
||||
|
||||
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
|
||||
if len(emails) > 0 {
|
||||
var stats []xray.ClientTraffic
|
||||
if err := q.db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overlayGlobalTrafficValues(q.db, stats)
|
||||
for i := range stats {
|
||||
trafficByEmail[stats[i].Email] = &stats[i]
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]ClientSlim, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
rec := byId[id]
|
||||
if rec == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, ClientSlim{
|
||||
Email: rec.Email,
|
||||
SubID: rec.SubID,
|
||||
Enable: rec.Enable,
|
||||
TotalGB: rec.TotalGB,
|
||||
ExpiryTime: rec.ExpiryTime,
|
||||
LimitIP: rec.LimitIP,
|
||||
Reset: rec.Reset,
|
||||
Group: rec.Group,
|
||||
Comment: rec.Comment,
|
||||
InboundIds: attachments[rec.Id],
|
||||
Traffic: trafficByEmail[rec.Email],
|
||||
CreatedAt: rec.CreatedAt,
|
||||
UpdatedAt: rec.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error) {
|
||||
s := ClientsSummary{
|
||||
Total: len(all),
|
||||
Total: total,
|
||||
Online: []string{},
|
||||
Depleted: []string{},
|
||||
Expiring: []string{},
|
||||
Deactive: []string{},
|
||||
}
|
||||
for _, c := range all {
|
||||
used := int64(0)
|
||||
if c.Traffic != nil {
|
||||
used = c.Traffic.Up + c.Traffic.Down
|
||||
|
||||
var counts struct {
|
||||
Active int64
|
||||
Depleted int64
|
||||
Expiring int64
|
||||
Deactive int64
|
||||
}
|
||||
// SUM over an empty table yields NULL, which not every driver scans into an
|
||||
// int; COALESCE keeps a panel with no clients from erroring out.
|
||||
if err := q.from().Select(
|
||||
"COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," +
|
||||
" COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," +
|
||||
" COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," +
|
||||
" COALESCE(SUM(CASE WHEN " + q.summaryDeactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
|
||||
).Scan(&counts).Error; err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Active = int(counts.Active)
|
||||
s.DepletedCount = int(counts.Depleted)
|
||||
s.ExpiringCount = int(counts.Expiring)
|
||||
s.DeactiveCount = int(counts.Deactive)
|
||||
|
||||
buckets := []struct {
|
||||
cond string
|
||||
count int
|
||||
out *[]string
|
||||
}{
|
||||
{q.depletedExpr(), s.DepletedCount, &s.Depleted},
|
||||
{q.expiringExpr(), s.ExpiringCount, &s.Expiring},
|
||||
{q.summaryDeactiveExpr(), s.DeactiveCount, &s.Deactive},
|
||||
}
|
||||
for _, b := range buckets {
|
||||
// The counter already says the bucket is empty, so skip the scan that
|
||||
// would look for emails it cannot find.
|
||||
if b.count == 0 {
|
||||
continue
|
||||
}
|
||||
exhausted := c.TotalGB > 0 && used >= c.TotalGB
|
||||
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
|
||||
if c.Enable {
|
||||
if _, ok := onlineSet[c.Email]; ok {
|
||||
s.Online = append(s.Online, c.Email)
|
||||
var emails []string
|
||||
if err := q.from().Where(b.cond).
|
||||
Order("c.id ASC").Limit(clientSummaryEmailCap).
|
||||
Pluck("c.email", &emails).Error; err != nil {
|
||||
return s, err
|
||||
}
|
||||
if len(emails) > 0 {
|
||||
*b.out = emails
|
||||
}
|
||||
}
|
||||
|
||||
online, onlineCount, err := q.onlineEmails(onlines)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Online = online
|
||||
s.OnlineCount = onlineCount
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// onlineEmails intersects the emails xray reports as connected with the enabled
|
||||
// clients this panel stores. The online set lives in memory and is bounded by
|
||||
// live connections, so it drives the query rather than a scan of every client.
|
||||
func (q clientQuery) onlineEmails(onlines []string) ([]string, int, error) {
|
||||
matched := []string{}
|
||||
count := 0
|
||||
for _, batch := range chunkStrings(onlines, sqlInChunk) {
|
||||
var page []string
|
||||
if err := q.db.Model(&model.ClientRecord{}).
|
||||
Where("COALESCE(enable, FALSE) = TRUE AND email IN ?", batch).
|
||||
Order("id ASC").
|
||||
Pluck("email", &page).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
count += len(page)
|
||||
if room := clientSummaryEmailCap - len(matched); room > 0 {
|
||||
matched = append(matched, page[:min(room, len(page))]...)
|
||||
}
|
||||
}
|
||||
return matched, count, nil
|
||||
}
|
||||
|
||||
// listGroupNames returns the group names the clients page offers as filters:
|
||||
// the stored groups plus any name a client still carries. ListGroups also sums
|
||||
// per-client traffic per group, which this page never reads and which costs a
|
||||
// full join over client_traffics on every poll.
|
||||
func (s *ClientService) listGroupNames() ([]string, error) {
|
||||
db := database.GetDB()
|
||||
var stored []string
|
||||
if err := db.Model(&model.ClientGroup{}).Pluck("name", &stored).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var used []string
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("group_name <> ''").
|
||||
Distinct().
|
||||
Pluck("group_name", &used).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]struct{}, len(stored)+len(used))
|
||||
out := make([]string, 0, len(stored)+len(used))
|
||||
for _, list := range [][]string{stored, used} {
|
||||
for _, name := range list {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if exhausted || expired {
|
||||
s.Depleted = append(s.Depleted, c.Email)
|
||||
continue
|
||||
}
|
||||
if !c.Enable {
|
||||
s.Deactive = append(s.Deactive, c.Email)
|
||||
continue
|
||||
}
|
||||
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
|
||||
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
|
||||
if nearExpiry || nearLimit {
|
||||
s.Expiring = append(s.Expiring, c.Email)
|
||||
} else {
|
||||
s.Active++
|
||||
if _, dup := seen[name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return s
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return strings.ToLower(out[i]) < strings.ToLower(out[j])
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func toClientSlim(c ClientWithAttachments) ClientSlim {
|
||||
return ClientSlim{
|
||||
Email: c.Email,
|
||||
SubID: c.SubID,
|
||||
Enable: c.Enable,
|
||||
TotalGB: c.TotalGB,
|
||||
ExpiryTime: c.ExpiryTime,
|
||||
LimitIP: c.LimitIP,
|
||||
Reset: c.Reset,
|
||||
Group: c.Group,
|
||||
Comment: c.Comment,
|
||||
InboundIds: c.InboundIds,
|
||||
Traffic: c.Traffic,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}
|
||||
func sqlInt(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
|
||||
func clientMatchesSearch(c ClientWithAttachments, needle string) bool {
|
||||
if needle == "" {
|
||||
return true
|
||||
// escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps
|
||||
// matching literally, the way strings.Contains did.
|
||||
func escapeLikeLiteral(s string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
||||
}
|
||||
|
||||
// emailInCond renders an IN over a possibly large email set, split so no single
|
||||
// IN list outgrows the drivers' bind-parameter ceiling.
|
||||
func emailInCond(column string, emails []string) (string, []any) {
|
||||
if len(emails) == 0 {
|
||||
return "1 = 0", nil
|
||||
}
|
||||
candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth}
|
||||
for _, v := range candidates {
|
||||
if v != "" && strings.Contains(strings.ToLower(v), needle) {
|
||||
return true
|
||||
}
|
||||
chunks := chunkStrings(emails, sqlInChunk)
|
||||
parts := make([]string, 0, len(chunks))
|
||||
args := make([]any, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
parts = append(parts, column+" IN ?")
|
||||
args = append(args, chunk)
|
||||
}
|
||||
if c.TgID != 0 && strings.Contains(strconv.FormatInt(c.TgID, 10), needle) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return "(" + strings.Join(parts, " OR ") + ")", args
|
||||
}
|
||||
|
||||
// parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
|
||||
@@ -339,246 +669,3 @@ func parseCSVInts(raw string) []int {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
|
||||
for _, id := range c.InboundIds {
|
||||
p := byInbound[id]
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(protocols, strings.ToLower(p)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
|
||||
for _, id := range c.InboundIds {
|
||||
if slices.Contains(inboundIds, id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
|
||||
for _, b := range buckets {
|
||||
if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
|
||||
if fromMs <= 0 && toMs <= 0 {
|
||||
return true
|
||||
}
|
||||
// expiryTime of 0 means "never expires"; treat it as outside any bounded
|
||||
// range so users filtering by date see only clients with concrete expiries.
|
||||
if c.ExpiryTime == 0 {
|
||||
return false
|
||||
}
|
||||
// Negative expiry is the "delayed start" sentinel; same treatment as never.
|
||||
if c.ExpiryTime < 0 {
|
||||
return false
|
||||
}
|
||||
if fromMs > 0 && c.ExpiryTime < fromMs {
|
||||
return false
|
||||
}
|
||||
if toMs > 0 && c.ExpiryTime > toMs {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
|
||||
if fromBytes <= 0 && toBytes <= 0 {
|
||||
return true
|
||||
}
|
||||
used := int64(0)
|
||||
if c.Traffic != nil {
|
||||
used = c.Traffic.Up + c.Traffic.Down
|
||||
}
|
||||
if fromBytes > 0 && used < fromBytes {
|
||||
return false
|
||||
}
|
||||
if toBytes > 0 && used > toBytes {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "on":
|
||||
return c.Reset > 0
|
||||
case "off":
|
||||
return c.Reset <= 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "yes":
|
||||
return c.TgID != 0
|
||||
case "no":
|
||||
return c.TgID == 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "yes":
|
||||
return strings.TrimSpace(c.Comment) != ""
|
||||
case "no":
|
||||
return strings.TrimSpace(c.Comment) == ""
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func clientMatchesAnyGroup(c ClientWithAttachments, csv string) bool {
|
||||
groups := parseCSVStrings(csv)
|
||||
if len(groups) == 0 {
|
||||
return true
|
||||
}
|
||||
current := strings.TrimSpace(c.Group)
|
||||
for _, g := range groups {
|
||||
if g == "" {
|
||||
if current == "" {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(g, current) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
|
||||
if bucket == "" {
|
||||
return true
|
||||
}
|
||||
used := int64(0)
|
||||
if c.Traffic != nil {
|
||||
used = c.Traffic.Up + c.Traffic.Down
|
||||
}
|
||||
exhausted := c.TotalGB > 0 && used >= c.TotalGB
|
||||
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
|
||||
switch bucket {
|
||||
case "online":
|
||||
if onlineSet == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := onlineSet[c.Email]
|
||||
return ok && c.Enable
|
||||
case "depleted":
|
||||
return exhausted || expired
|
||||
case "deactive":
|
||||
return !c.Enable
|
||||
case "active":
|
||||
return c.Enable && !exhausted && !expired
|
||||
case "expiring":
|
||||
if !c.Enable || exhausted || expired {
|
||||
return false
|
||||
}
|
||||
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
|
||||
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
|
||||
return nearExpiry || nearLimit
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sortClients(rows []ClientWithAttachments, sortKey, order string) {
|
||||
if sortKey == "" {
|
||||
return
|
||||
}
|
||||
desc := order == "descend"
|
||||
less := func(i, j int) bool {
|
||||
a, b := rows[i], rows[j]
|
||||
switch sortKey {
|
||||
case "enable":
|
||||
if a.Enable == b.Enable {
|
||||
return false
|
||||
}
|
||||
return !a.Enable && b.Enable
|
||||
case "email":
|
||||
return strings.ToLower(a.Email) < strings.ToLower(b.Email)
|
||||
case "inboundIds":
|
||||
return len(a.InboundIds) < len(b.InboundIds)
|
||||
case "traffic":
|
||||
ua := int64(0)
|
||||
if a.Traffic != nil {
|
||||
ua = a.Traffic.Up + a.Traffic.Down
|
||||
}
|
||||
ub := int64(0)
|
||||
if b.Traffic != nil {
|
||||
ub = b.Traffic.Up + b.Traffic.Down
|
||||
}
|
||||
return ua < ub
|
||||
case "remaining":
|
||||
ra := int64(1<<62 - 1)
|
||||
if a.TotalGB > 0 {
|
||||
used := int64(0)
|
||||
if a.Traffic != nil {
|
||||
used = a.Traffic.Up + a.Traffic.Down
|
||||
}
|
||||
ra = a.TotalGB - used
|
||||
}
|
||||
rb := int64(1<<62 - 1)
|
||||
if b.TotalGB > 0 {
|
||||
used := int64(0)
|
||||
if b.Traffic != nil {
|
||||
used = b.Traffic.Up + b.Traffic.Down
|
||||
}
|
||||
rb = b.TotalGB - used
|
||||
}
|
||||
return ra < rb
|
||||
case "expiryTime":
|
||||
ea := int64(1<<62 - 1)
|
||||
if a.ExpiryTime > 0 {
|
||||
ea = a.ExpiryTime
|
||||
}
|
||||
eb := int64(1<<62 - 1)
|
||||
if b.ExpiryTime > 0 {
|
||||
eb = b.ExpiryTime
|
||||
}
|
||||
return ea < eb
|
||||
case "createdAt":
|
||||
if a.CreatedAt == b.CreatedAt {
|
||||
return a.Id < b.Id
|
||||
}
|
||||
return a.CreatedAt < b.CreatedAt
|
||||
case "updatedAt":
|
||||
if a.UpdatedAt == b.UpdatedAt {
|
||||
return a.Id < b.Id
|
||||
}
|
||||
return a.UpdatedAt < b.UpdatedAt
|
||||
case "lastOnline":
|
||||
la := int64(0)
|
||||
if a.Traffic != nil {
|
||||
la = a.Traffic.LastOnline
|
||||
}
|
||||
lb := int64(0)
|
||||
if b.Traffic != nil {
|
||||
lb = b.Traffic.LastOnline
|
||||
}
|
||||
if la == lb {
|
||||
return a.Id < b.Id
|
||||
}
|
||||
return la < lb
|
||||
}
|
||||
return false
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if desc {
|
||||
return less(j, i)
|
||||
}
|
||||
return less(i, j)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
pagingDay = int64(86400000)
|
||||
pagingGB = int64(1) << 30
|
||||
)
|
||||
|
||||
type pagingSeed struct {
|
||||
email string
|
||||
enable bool
|
||||
totalGB int64
|
||||
expiryTime int64
|
||||
used int64
|
||||
subID string
|
||||
uuid string
|
||||
password string
|
||||
auth string
|
||||
comment string
|
||||
group string
|
||||
tgID int64
|
||||
reset int
|
||||
lastOnline int64
|
||||
inbounds []int
|
||||
}
|
||||
|
||||
// seedPagingClients writes one vless and one trojan inbound plus a fixed client
|
||||
// set covering every bucket, sort key and search field ListPaged supports.
|
||||
// Returns "now" so the expectations can be phrased relative to it.
|
||||
func seedPagingClients(t *testing.T) (int64, []pagingSeed) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
vless := &model.Inbound{UserId: 1, Tag: "in-vless", Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||
trojan := &model.Inbound{UserId: 1, Tag: "in-trojan", Enable: true, Port: 40002, Protocol: model.Trojan, Settings: `{"clients":[]}`}
|
||||
for _, ib := range []*model.Inbound{vless, trojan} {
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound %s: %v", ib.Tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
seeds := []pagingSeed{
|
||||
{email: "alpha@x", enable: true, totalGB: 0, expiryTime: 0, used: 5 * pagingGB, subID: "sub-alpha", uuid: "uuid-alpha", inbounds: []int{vless.Id}, lastOnline: now - 10*pagingDay},
|
||||
{email: "bravo@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: pagingGB, password: "pw-bravo", inbounds: []int{vless.Id, trojan.Id}, lastOnline: now - pagingDay},
|
||||
{email: "charlie@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10 * pagingGB, auth: "auth-charlie", inbounds: []int{trojan.Id}},
|
||||
{email: "delta@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, used: pagingGB, inbounds: []int{vless.Id}},
|
||||
{email: "echo@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, inbounds: []int{vless.Id}},
|
||||
{email: "foxtrot@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, inbounds: []int{trojan.Id}},
|
||||
{email: "golf@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 2*pagingDay, inbounds: []int{vless.Id}},
|
||||
{email: "hotel@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10*pagingGB - pagingGB/2, inbounds: []int{vless.Id}},
|
||||
{email: "india@x", enable: true, totalGB: 0, expiryTime: -5 * pagingDay, inbounds: []int{vless.Id}},
|
||||
{email: "juliet@x", enable: true, comment: " vip customer ", group: "VIP", tgID: 555, reset: 7, inbounds: []int{vless.Id}},
|
||||
{email: "kilo_1@x", enable: true, group: "vip", inbounds: nil},
|
||||
{email: "kilo1@x", enable: true, inbounds: []int{trojan.Id}},
|
||||
}
|
||||
|
||||
for i, s := range seeds {
|
||||
rec := model.ClientRecord{
|
||||
Email: s.email,
|
||||
SubID: s.subID,
|
||||
UUID: s.uuid,
|
||||
Password: s.password,
|
||||
Auth: s.auth,
|
||||
Comment: s.comment,
|
||||
Group: s.group,
|
||||
TgID: s.tgID,
|
||||
Reset: s.reset,
|
||||
Enable: s.enable,
|
||||
TotalGB: s.totalGB,
|
||||
ExpiryTime: s.expiryTime,
|
||||
CreatedAt: now - int64(len(seeds)-i)*pagingDay,
|
||||
UpdatedAt: now - int64(i)*pagingDay,
|
||||
}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
t.Fatalf("create client %s: %v", s.email, err)
|
||||
}
|
||||
if !s.enable {
|
||||
// clients.enable carries a `default:true` tag, so GORM leaves the
|
||||
// zero value out of the INSERT and the column comes back true.
|
||||
// Restate updated_at so the autoUpdateTime hook cannot reshuffle
|
||||
// the sort fixtures.
|
||||
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).
|
||||
Updates(map[string]any{"enable": false, "updated_at": rec.UpdatedAt}).Error; err != nil {
|
||||
t.Fatalf("disable %s: %v", s.email, err)
|
||||
}
|
||||
}
|
||||
traffic := xray.ClientTraffic{
|
||||
Email: s.email,
|
||||
Enable: s.enable,
|
||||
Up: s.used / 2,
|
||||
Down: s.used - s.used/2,
|
||||
Total: s.totalGB,
|
||||
ExpiryTime: s.expiryTime,
|
||||
LastOnline: s.lastOnline,
|
||||
}
|
||||
if err := db.Create(&traffic).Error; err != nil {
|
||||
t.Fatalf("create traffic %s: %v", s.email, err)
|
||||
}
|
||||
for _, id := range s.inbounds {
|
||||
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: id}).Error; err != nil {
|
||||
t.Fatalf("attach %s to %d: %v", s.email, id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return now, seeds
|
||||
}
|
||||
|
||||
func pagedEmails(items []ClientSlim) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, it.Email)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func setupPagingServices(t *testing.T) (*ClientService, *InboundService, *SettingService) {
|
||||
t.Helper()
|
||||
setupBulkDB(t)
|
||||
settingSvc := &SettingService{}
|
||||
if err := settingSvc.setInt("expireDiff", 3); err != nil {
|
||||
t.Fatalf("set expireDiff: %v", err)
|
||||
}
|
||||
if err := settingSvc.setInt("trafficDiff", 1); err != nil {
|
||||
t.Fatalf("set trafficDiff: %v", err)
|
||||
}
|
||||
return &ClientService{}, &InboundService{}, settingSvc
|
||||
}
|
||||
|
||||
func TestListPagedFilters(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
now, _ := seedPagingClients(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
params ClientPageParams
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "no filter returns every client in id order",
|
||||
params: ClientPageParams{PageSize: 50},
|
||||
want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "depleted bucket covers quota and expiry",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "depleted"},
|
||||
want: []string{"charlie@x", "delta@x", "foxtrot@x"},
|
||||
},
|
||||
{
|
||||
name: "deactive bucket is every disabled client",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "deactive"},
|
||||
want: []string{"echo@x", "foxtrot@x"},
|
||||
},
|
||||
{
|
||||
name: "expiring bucket covers near expiry and near quota",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "expiring"},
|
||||
want: []string{"golf@x", "hotel@x"},
|
||||
},
|
||||
{
|
||||
name: "active bucket keeps enabled clients that still have room",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "active"},
|
||||
want: []string{"alpha@x", "bravo@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "buckets are ORed",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "depleted,expiring"},
|
||||
want: []string{"charlie@x", "delta@x", "foxtrot@x", "golf@x", "hotel@x"},
|
||||
},
|
||||
{
|
||||
name: "unknown bucket keeps matching everything",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "nonsense"},
|
||||
want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "protocol filter follows the attachments",
|
||||
params: ClientPageParams{PageSize: 50, Protocol: "trojan"},
|
||||
want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "inbound filter follows the attachments",
|
||||
params: ClientPageParams{PageSize: 50, Inbound: "2"},
|
||||
want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the email",
|
||||
params: ClientPageParams{PageSize: 50, Search: "KILO"},
|
||||
want: []string{"kilo_1@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "search treats LIKE wildcards literally",
|
||||
params: ClientPageParams{PageSize: 50, Search: "kilo_1"},
|
||||
want: []string{"kilo_1@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the subId",
|
||||
params: ClientPageParams{PageSize: 50, Search: "sub-alpha"},
|
||||
want: []string{"alpha@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the uuid",
|
||||
params: ClientPageParams{PageSize: 50, Search: "uuid-alpha"},
|
||||
want: []string{"alpha@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the password",
|
||||
params: ClientPageParams{PageSize: 50, Search: "pw-bravo"},
|
||||
want: []string{"bravo@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the auth",
|
||||
params: ClientPageParams{PageSize: 50, Search: "auth-charlie"},
|
||||
want: []string{"charlie@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the comment",
|
||||
params: ClientPageParams{PageSize: 50, Search: "vip customer"},
|
||||
want: []string{"juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the telegram id",
|
||||
params: ClientPageParams{PageSize: 50, Search: "555"},
|
||||
want: []string{"juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "group filter is case insensitive",
|
||||
params: ClientPageParams{PageSize: 50, Group: "vip"},
|
||||
want: []string{"juliet@x", "kilo_1@x"},
|
||||
},
|
||||
{
|
||||
name: "hasComment yes",
|
||||
params: ClientPageParams{PageSize: 50, HasComment: "yes"},
|
||||
want: []string{"juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "hasTgId yes",
|
||||
params: ClientPageParams{PageSize: 50, HasTgID: "yes"},
|
||||
want: []string{"juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "autoRenew on",
|
||||
params: ClientPageParams{PageSize: 50, AutoRenew: "on"},
|
||||
want: []string{"juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "usage range is inclusive on both bounds",
|
||||
params: ClientPageParams{PageSize: 50, UsageFrom: pagingGB, UsageTo: 5 * pagingGB},
|
||||
want: []string{"alpha@x", "bravo@x", "delta@x"},
|
||||
},
|
||||
{
|
||||
name: "expiry range excludes never and delayed start",
|
||||
params: ClientPageParams{PageSize: 50, ExpiryFrom: now, ExpiryTo: now + 10*pagingDay},
|
||||
want: []string{"golf@x"},
|
||||
},
|
||||
{
|
||||
name: "filters combine with AND",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "depleted", Protocol: "trojan"},
|
||||
want: []string{"charlie@x", "foxtrot@x"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, tc.params)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
got := pagedEmails(resp.Items)
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("emails = %v, want %v", got, tc.want)
|
||||
}
|
||||
if resp.Filtered != len(tc.want) {
|
||||
t.Fatalf("filtered = %d, want %d", resp.Filtered, len(tc.want))
|
||||
}
|
||||
if resp.Total != 12 {
|
||||
t.Fatalf("total = %d, want 12", resp.Total)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPagedSorting(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
seedPagingClients(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sort string
|
||||
order string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "no sort key keeps insertion order",
|
||||
want: []string{"alpha@x", "bravo@x", "charlie@x"},
|
||||
},
|
||||
{
|
||||
name: "email ascending", sort: "email", order: "ascend",
|
||||
want: []string{"alpha@x", "bravo@x", "charlie@x"},
|
||||
},
|
||||
{
|
||||
name: "email descending", sort: "email", order: "descend",
|
||||
want: []string{"kilo_1@x", "kilo1@x", "juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "traffic descending", sort: "traffic", order: "descend",
|
||||
want: []string{"charlie@x", "hotel@x", "alpha@x"},
|
||||
},
|
||||
{
|
||||
name: "remaining descending puts unlimited quotas first", sort: "remaining", order: "descend",
|
||||
want: []string{"alpha@x", "india@x", "juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "expiry ascending starts with the expired", sort: "expiryTime", order: "ascend",
|
||||
want: []string{"delta@x", "foxtrot@x", "golf@x"},
|
||||
},
|
||||
{
|
||||
name: "createdAt ascending follows insertion", sort: "createdAt", order: "ascend",
|
||||
want: []string{"alpha@x", "bravo@x", "charlie@x"},
|
||||
},
|
||||
{
|
||||
name: "updatedAt descending starts with the newest", sort: "updatedAt", order: "descend",
|
||||
want: []string{"alpha@x", "bravo@x", "charlie@x"},
|
||||
},
|
||||
{
|
||||
name: "lastOnline descending breaks ties on the id, reversed too", sort: "lastOnline", order: "descend",
|
||||
want: []string{"bravo@x", "alpha@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "enable ascending puts disabled first", sort: "enable", order: "ascend",
|
||||
want: []string{"echo@x", "foxtrot@x", "alpha@x"},
|
||||
},
|
||||
{
|
||||
name: "inboundIds descending puts the widest attachment first", sort: "inboundIds", order: "descend",
|
||||
want: []string{"bravo@x", "alpha@x", "charlie@x"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 3, Sort: tc.sort, Order: tc.order})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
got := pagedEmails(resp.Items)
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("emails = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPagedPagination(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
seedPagingClients(t)
|
||||
|
||||
t.Run("second page continues where the first stopped", func(t *testing.T) {
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 2, PageSize: 5, Sort: "email", Order: "ascend"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
want := []string{"foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x"}
|
||||
if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
|
||||
t.Fatalf("emails = %v, want %v", got, want)
|
||||
}
|
||||
if resp.Page != 2 || resp.PageSize != 5 {
|
||||
t.Fatalf("page/pageSize = %d/%d, want 2/5", resp.Page, resp.PageSize)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("page past the end is empty but keeps the counts", func(t *testing.T) {
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 9, PageSize: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 0 {
|
||||
t.Fatalf("items = %v, want none", pagedEmails(resp.Items))
|
||||
}
|
||||
if resp.Filtered != 12 || resp.Total != 12 {
|
||||
t.Fatalf("filtered/total = %d/%d, want 12/12", resp.Filtered, resp.Total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("page size is clamped to the maximum", func(t *testing.T) {
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 5000})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
if resp.PageSize != clientPageMaxSize {
|
||||
t.Fatalf("pageSize = %d, want %d", resp.PageSize, clientPageMaxSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListPagedRowContents(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
seedPagingClients(t)
|
||||
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
byEmail := make(map[string]ClientSlim, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
byEmail[it.Email] = it
|
||||
}
|
||||
|
||||
t.Run("attachments are reported in inbound order", func(t *testing.T) {
|
||||
got := byEmail["bravo@x"].InboundIds
|
||||
if !slices.Equal(got, []int{1, 2}) {
|
||||
t.Fatalf("inboundIds = %v, want [1 2]", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unattached client reports no inbounds", func(t *testing.T) {
|
||||
if got := byEmail["kilo_1@x"].InboundIds; len(got) != 0 {
|
||||
t.Fatalf("inboundIds = %v, want none", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("traffic counters ride along with the row", func(t *testing.T) {
|
||||
got := byEmail["hotel@x"].Traffic
|
||||
if got == nil {
|
||||
t.Fatal("traffic = nil, want the seeded counters")
|
||||
}
|
||||
if want := 10*pagingGB - pagingGB/2; got.Up+got.Down != want {
|
||||
t.Fatalf("used = %d, want %d", got.Up+got.Down, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("groups list every name in use", func(t *testing.T) {
|
||||
if !slices.Equal(resp.Groups, []string{"vip", "VIP"}) && !slices.Equal(resp.Groups, []string{"VIP", "vip"}) {
|
||||
t.Fatalf("groups = %v, want VIP and vip", resp.Groups)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListPagedSummary(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
seedPagingClients(t)
|
||||
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 5, Filter: "depleted"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
s := resp.Summary
|
||||
|
||||
t.Run("counts stay whole-panel while the page is filtered", func(t *testing.T) {
|
||||
if s.Total != 12 {
|
||||
t.Fatalf("total = %d, want 12", s.Total)
|
||||
}
|
||||
if s.DepletedCount != 3 {
|
||||
t.Fatalf("depletedCount = %d, want 3", s.DepletedCount)
|
||||
}
|
||||
if s.ExpiringCount != 2 {
|
||||
t.Fatalf("expiringCount = %d, want 2", s.ExpiringCount)
|
||||
}
|
||||
if s.DeactiveCount != 1 {
|
||||
t.Fatalf("deactiveCount = %d, want 1", s.DeactiveCount)
|
||||
}
|
||||
if s.Active != 6 {
|
||||
t.Fatalf("active = %d, want 6", s.Active)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("every client lands in exactly one counter", func(t *testing.T) {
|
||||
if sum := s.Active + s.DepletedCount + s.ExpiringCount + s.DeactiveCount; sum != s.Total {
|
||||
t.Fatalf("buckets sum to %d, want %d", sum, s.Total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bucket lists carry the matching emails", func(t *testing.T) {
|
||||
if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) {
|
||||
t.Fatalf("depleted = %v, want %v", s.Depleted, want)
|
||||
}
|
||||
if want := []string{"golf@x", "hotel@x"}; !slices.Equal(s.Expiring, want) {
|
||||
t.Fatalf("expiring = %v, want %v", s.Expiring, want)
|
||||
}
|
||||
if want := []string{"echo@x"}; !slices.Equal(s.Deactive, want) {
|
||||
t.Fatalf("deactive = %v, want %v", s.Deactive, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListPagedSummaryEmailListsAreCapped(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
db := database.GetDB()
|
||||
|
||||
const n = clientSummaryEmailCap + 25
|
||||
past := time.Now().UnixMilli() - pagingDay
|
||||
for i := range n {
|
||||
rec := model.ClientRecord{Email: "bulk-" + strconv.Itoa(i) + "@x", Enable: true, TotalGB: pagingGB, ExpiryTime: past}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
t.Fatalf("create client %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 25})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
if resp.Summary.DepletedCount != n {
|
||||
t.Fatalf("depletedCount = %d, want %d", resp.Summary.DepletedCount, n)
|
||||
}
|
||||
if len(resp.Summary.Depleted) != clientSummaryEmailCap {
|
||||
t.Fatalf("depleted list = %d entries, want %d", len(resp.Summary.Depleted), clientSummaryEmailCap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPagedGlobalTrafficOverlay(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
seedPagingClients(t)
|
||||
|
||||
// bravo has used 1GB of its 10GB locally; a master reporting 10GB of
|
||||
// cross-panel usage has to move it into the depleted bucket.
|
||||
if err := inboundSvc.AcceptGlobalTraffic("master-guid", []*xray.ClientTraffic{
|
||||
{Email: "bravo@x", Up: 4 * pagingGB, Down: 6 * pagingGB},
|
||||
}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||
}
|
||||
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50, Filter: "depleted"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
want := []string{"bravo@x", "charlie@x", "delta@x", "foxtrot@x"}
|
||||
if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
|
||||
t.Fatalf("depleted = %v, want %v", got, want)
|
||||
}
|
||||
if resp.Summary.DepletedCount != 4 {
|
||||
t.Fatalf("depletedCount = %d, want 4", resp.Summary.DepletedCount)
|
||||
}
|
||||
for _, it := range resp.Items {
|
||||
if it.Email != "bravo@x" {
|
||||
continue
|
||||
}
|
||||
if it.Traffic == nil || it.Traffic.Up+it.Traffic.Down != 10*pagingGB {
|
||||
t.Fatalf("bravo traffic = %+v, want the overlaid 10GB", it.Traffic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientQueryOnlineEmails(t *testing.T) {
|
||||
_, _, _ = setupPagingServices(t)
|
||||
seedPagingClients(t)
|
||||
|
||||
q := newClientQuery(database.GetDB(), time.Now().UnixMilli(), 0, 0)
|
||||
emails, count, err := q.onlineEmails([]string{"alpha@x", "echo@x", "ghost@x", "kilo1@x"})
|
||||
if err != nil {
|
||||
t.Fatalf("onlineEmails: %v", err)
|
||||
}
|
||||
if want := []string{"alpha@x", "kilo1@x"}; !slices.Equal(emails, want) {
|
||||
t.Fatalf("online = %v, want %v (disabled and unknown emails drop out)", emails, want)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailInCondChunksLargeSets(t *testing.T) {
|
||||
emails := make([]string, sqlInChunk+1)
|
||||
for i := range emails {
|
||||
emails[i] = "e" + strconv.Itoa(i)
|
||||
}
|
||||
|
||||
cond, args := emailInCond("c.email", emails)
|
||||
if want := "(c.email IN ? OR c.email IN ?)"; cond != want {
|
||||
t.Fatalf("cond = %q, want %q", cond, want)
|
||||
}
|
||||
if len(args) != 2 {
|
||||
t.Fatalf("args = %d chunks, want 2", len(args))
|
||||
}
|
||||
if first, ok := args[0].([]string); !ok || len(first) != sqlInChunk {
|
||||
t.Fatalf("first chunk = %v, want %d entries", args[0], sqlInChunk)
|
||||
}
|
||||
|
||||
emptyCond, emptyArgs := emailInCond("c.email", nil)
|
||||
if emptyCond != "1 = 0" || emptyArgs != nil {
|
||||
t.Fatalf("empty set = %q/%v, want an always-false predicate", emptyCond, emptyArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeLikeLiteral(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"plain", "plain"},
|
||||
{"a_b", `a\_b`},
|
||||
{"50%", `50\%`},
|
||||
{`back\slash`, `back\\slash`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := escapeLikeLiteral(tc.in); got != tc.want {
|
||||
t.Fatalf("escapeLikeLiteral(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPagedEmptyPanel(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged on a panel with no clients: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 0 || resp.Total != 0 || resp.Filtered != 0 {
|
||||
t.Fatalf("items/total/filtered = %d/%d/%d, want 0/0/0", len(resp.Items), resp.Total, resp.Filtered)
|
||||
}
|
||||
if resp.Summary.Active != 0 || resp.Summary.DepletedCount != 0 {
|
||||
t.Fatalf("summary = %+v, want zeroed counters", resp.Summary)
|
||||
}
|
||||
if resp.Groups == nil {
|
||||
t.Fatal("groups = nil, want an empty list so the filter drawer renders")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// How long a client stays recoverable after a node merge concluded it is gone.
|
||||
// Any merge that sees it attached again inside this window clears the mark.
|
||||
const syncOrphanReapGrace = 15 * time.Minute
|
||||
|
||||
// A traffic row with no clients row behind it is drift the orphan sweep will
|
||||
// never mark, so it stays on the old delete-immediately path.
|
||||
func clientRecordExists(tx *gorm.DB, email string) bool {
|
||||
var n int64
|
||||
if err := tx.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&n).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func markSyncOrphan(tx *gorm.DB, email string, nowMs int64) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.ClientRecord{}).
|
||||
Where("email = ? AND sync_orphaned_at = 0", email).
|
||||
Update("sync_orphaned_at", nowMs).Error
|
||||
}
|
||||
|
||||
// A client that is attached again is not orphaned, whatever an earlier merge
|
||||
// concluded — this is what makes a bad merge recoverable instead of fatal.
|
||||
func clearSyncOrphanMarks(tx *gorm.DB) error {
|
||||
return tx.Model(&model.ClientRecord{}).
|
||||
Where("sync_orphaned_at > 0 AND EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
|
||||
Update("sync_orphaned_at", 0).Error
|
||||
}
|
||||
|
||||
// ReapSyncOrphans deletes the clients the node-snapshot sweep marked and no
|
||||
// later merge reclaimed. It is the only path that hard-deletes for that sweep.
|
||||
func (s *ClientService) ReapSyncOrphans() (int, error) {
|
||||
db := database.GetDB()
|
||||
cutoff := time.Now().Add(-syncOrphanReapGrace).UnixMilli()
|
||||
|
||||
var emails []string
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("sync_orphaned_at > 0 AND sync_orphaned_at <= ?", cutoff).
|
||||
Where("NOT EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
|
||||
Pluck("email", &emails).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(emails) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
reaped := 0
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
if err := runSerializedTx(func(tx *gorm.DB) error {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error
|
||||
}); err != nil {
|
||||
return reaped, err
|
||||
}
|
||||
reaped += len(batch)
|
||||
logger.Infof("reaped %d client(s) confirmed removed on their node", len(batch))
|
||||
}
|
||||
return reaped, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func readOrphanMark(t *testing.T, db *gorm.DB, email string) int64 {
|
||||
t.Helper()
|
||||
var row model.ClientRecord
|
||||
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
|
||||
t.Fatalf("read client %q: %v", email, err)
|
||||
}
|
||||
return row.SyncOrphanedAt
|
||||
}
|
||||
|
||||
func backdateOrphanMark(t *testing.T, db *gorm.DB, email string) {
|
||||
t.Helper()
|
||||
past := time.Now().Add(-2 * syncOrphanReapGrace).UnixMilli()
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("email = ?", email).
|
||||
Update("sync_orphaned_at", past).Error; err != nil {
|
||||
t.Fatalf("backdate orphan mark: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The merge must soft-orphan, not delete: everything stays recoverable until
|
||||
// the grace period has elapsed and the reaper confirms nothing reclaimed it.
|
||||
func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
|
||||
const email = "gone@x"
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
||||
|
||||
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
||||
t.Fatalf("setup: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
|
||||
t.Fatalf("orphaning merge: %v", err)
|
||||
}
|
||||
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
||||
t.Fatalf("merge hard-deleted the client: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
if readOrphanMark(t, db, email) <= 0 {
|
||||
t.Fatal("merge did not stamp sync_orphaned_at")
|
||||
}
|
||||
|
||||
reaped, err := clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap inside grace: %v", err)
|
||||
}
|
||||
if reaped != 0 {
|
||||
t.Fatalf("reaped %d client(s) inside the grace period, want 0", reaped)
|
||||
}
|
||||
if rec, _ := countClientRows(t, db, email); rec != 1 {
|
||||
t.Fatal("client removed before the grace period elapsed")
|
||||
}
|
||||
|
||||
backdateOrphanMark(t, db, email)
|
||||
reaped, err = clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap after grace: %v", err)
|
||||
}
|
||||
if reaped != 1 {
|
||||
t.Fatalf("reaped %d client(s) after the grace period, want 1", reaped)
|
||||
}
|
||||
rec, traf := countClientRows(t, db, email)
|
||||
if rec != 0 || traf != 0 {
|
||||
t.Fatalf("after reap: clients=%d client_traffics=%d, want 0/0", rec, traf)
|
||||
}
|
||||
}
|
||||
|
||||
// A client the node reports again was never gone: clearing the mark is what
|
||||
// turns a bad merge into a recoverable blip instead of a delayed deletion.
|
||||
func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
|
||||
const email = "flaky@x"
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
||||
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
|
||||
t.Fatalf("orphaning merge: %v", err)
|
||||
}
|
||||
if readOrphanMark(t, db, email) <= 0 {
|
||||
t.Fatal("setup: expected the merge to mark the client")
|
||||
}
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true})
|
||||
|
||||
if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 {
|
||||
t.Fatalf("re-attached client kept its orphan mark: sync_orphaned_at=%d", orphanedAt)
|
||||
}
|
||||
|
||||
backdateOrphanMark(t, db, email)
|
||||
if reaped, err := clientSvc.ReapSyncOrphans(); err != nil || reaped != 0 {
|
||||
t.Fatalf("reaped %d client(s) (err=%v) that the node still reports, want 0", reaped, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The reaper is scoped to the node sweep. Orphans from any other cause carry no
|
||||
// mark and keep their existing manual-cleanup semantics.
|
||||
func TestReapSyncOrphansIgnoresUnmarkedOrphans(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
const email = "manual@x"
|
||||
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "44444444-4444-4444-4444-444444444444"}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
|
||||
reaped, err := clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap: %v", err)
|
||||
}
|
||||
if reaped != 0 {
|
||||
t.Fatalf("reaped %d unmarked orphan(s), want 0", reaped)
|
||||
}
|
||||
var surviving int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil {
|
||||
t.Fatalf("count clients: %v", err)
|
||||
}
|
||||
if surviving != 1 {
|
||||
t.Fatalf("unmarked orphan was reaped: %d rows survive, want 1", surviving)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,45 @@ func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdateKeepsSharedSubIDEditable(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{
|
||||
{Email: "a@node", ID: "aaaaaaaa-0000-0000-0000-000000000005", SubID: "sub-shared", Enable: true},
|
||||
{Email: "b@node", ID: "aaaaaaaa-0000-0000-0000-000000000006", SubID: "sub-shared", Enable: true},
|
||||
}
|
||||
ib := mkInbound(t, 22004, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
first := lookupClientRecord(t, "a@node")
|
||||
if first.SubID != "sub-shared" || lookupClientRecord(t, "b@node").SubID != "sub-shared" {
|
||||
t.Fatalf("seed did not produce a shared subId")
|
||||
}
|
||||
|
||||
updated := source[0]
|
||||
updated.TotalGB = 42
|
||||
if _, err := svc.Update(inboundSvc, first.Id, updated); err != nil {
|
||||
t.Fatalf("Update of a client whose subId is already shared: %v", err)
|
||||
}
|
||||
if got := lookupClientRecord(t, "a@node").TotalGB; got != 42 {
|
||||
t.Fatalf("totalGB after update = %d, want 42", got)
|
||||
}
|
||||
|
||||
omitted := source[0]
|
||||
omitted.SubID = ""
|
||||
omitted.TotalGB = 43
|
||||
if _, err := svc.Update(inboundSvc, first.Id, omitted); err != nil {
|
||||
t.Fatalf("Update with subId omitted entirely: %v", err)
|
||||
}
|
||||
other := lookupClientRecord(t, "b@node")
|
||||
if other.SubID != "sub-shared" {
|
||||
t.Fatalf("other client subId = %q, want %q", other.SubID, "sub-shared")
|
||||
}
|
||||
}
|
||||
|
||||
func mustInboundSettings(t *testing.T, inboundSvc *InboundService, id int) string {
|
||||
t.Helper()
|
||||
ib, err := inboundSvc.GetInbound(id)
|
||||
|
||||
@@ -46,11 +46,23 @@ func wireguardAllocationBase(used []string, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const wireguardPoolFloorBits = 16
|
||||
|
||||
// allocateWireguardAddress returns the first free single-host address in base
|
||||
// (suffixed /32 for an IPv4 base, /128 for IPv6) that is not already present
|
||||
// in used. The server holds the first host (.1 / ::1), so allocation starts
|
||||
// at the second host (.2 / ::2).
|
||||
func allocateWireguardAddress(used []string, base string) (string, error) {
|
||||
// at the second host (.2 / ::2). When allowWidening is set and base is an
|
||||
// IPv4 prefix narrower than wireguardPoolFloorBits, falls back to a wider
|
||||
// /16 scope once the configured base is exhausted, so a small pool doesn't
|
||||
// hard-fail while a wider address space is genuinely available.
|
||||
//
|
||||
// allowWidening must be false for AmneziaWG: unlike plain WireGuard, an
|
||||
// AmneziaWG peer's address must fall inside the kernel interface's own
|
||||
// configured Address subnet to be routable at all, so silently handing out
|
||||
// an address from a wider scope would produce an address the interface can
|
||||
// never actually reach — better to fail loudly on pool exhaustion than hand
|
||||
// out a client config that will never connect.
|
||||
func allocateWireguardAddress(used []string, base string, allowWidening bool) (string, error) {
|
||||
if base == "" {
|
||||
base = defaultWireguardBase
|
||||
}
|
||||
@@ -68,14 +80,22 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
|
||||
taken[a] = struct{}{}
|
||||
}
|
||||
}
|
||||
addr := prefix.Masked().Addr().Next().Next()
|
||||
for prefix.Contains(addr) {
|
||||
if _, ok := taken[addr]; !ok {
|
||||
return addr.String() + "/" + hostBits, nil
|
||||
scopes := []netip.Prefix{prefix}
|
||||
if allowWidening && prefix.Addr().Is4() && prefix.Bits() > wireguardPoolFloorBits {
|
||||
if wider, wErr := prefix.Addr().Prefix(wireguardPoolFloorBits); wErr == nil {
|
||||
scopes = append(scopes, wider)
|
||||
}
|
||||
addr = addr.Next()
|
||||
}
|
||||
return "", common.NewError("wireguard: no free address available in", base)
|
||||
for _, scope := range scopes {
|
||||
addr := scope.Masked().Addr().Next().Next()
|
||||
for scope.Contains(addr) {
|
||||
if _, ok := taken[addr]; !ok {
|
||||
return addr.String() + "/" + hostBits, nil
|
||||
}
|
||||
addr = addr.Next()
|
||||
}
|
||||
}
|
||||
return "", common.NewError("wireguard: no free address available in", scopes[len(scopes)-1].String())
|
||||
}
|
||||
|
||||
// normalizeWireguardAllowedIPs validates user-supplied allowedIPs entries and
|
||||
@@ -148,7 +168,7 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
|
||||
c.PublicKey = pub
|
||||
}
|
||||
if len(c.AllowedIPs) == 0 {
|
||||
addr, err := allocateWireguardAddress(used, base)
|
||||
addr, err := allocateWireguardAddress(used, base, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
@@ -20,11 +21,12 @@ func TestAllocateWireguardAddress(t *testing.T) {
|
||||
{name: "fills gap", used: []string{"10.0.0.3/32", "10.0.0.4/32"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
|
||||
{name: "ignores catch-all", used: []string{"0.0.0.0/0", "::/0"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
|
||||
{name: "default base when empty", used: nil, base: "", want: "10.0.0.2/32"},
|
||||
{name: "exhausted /30", used: []string{"10.9.0.2/32", "10.9.0.3/32"}, base: "10.9.0.0/30", err: true},
|
||||
{name: "full ipv4 scope widens instead of failing", used: []string{"10.9.0.2/32", "10.9.0.3/32"}, base: "10.9.0.0/30", want: "10.9.0.4/32"},
|
||||
{name: "exhausted ipv6 scope errors", used: []string{"fd00::2/128", "fd00::3/128"}, base: "fd00::/126", err: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := allocateWireguardAddress(tt.used, tt.base)
|
||||
got, err := allocateWireguardAddress(tt.used, tt.base, true)
|
||||
if tt.err {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %q", got)
|
||||
@@ -130,6 +132,54 @@ func TestDefaultWireguardClientsHonorsExistingSubnet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateWireguardAddressWidensPastFullSlash24(t *testing.T) {
|
||||
used := make([]string, 0, 254)
|
||||
for i := 2; i <= 255; i++ {
|
||||
used = append(used, fmt.Sprintf("10.0.0.%d/32", i))
|
||||
}
|
||||
|
||||
got, err := allocateWireguardAddress(used, "10.0.0.0/24", true)
|
||||
if err != nil {
|
||||
t.Fatalf("allocate with a full /24: %v", err)
|
||||
}
|
||||
if got != "10.0.1.0/32" {
|
||||
t.Fatalf("address after a full /24 = %q, want 10.0.1.0/32", got)
|
||||
}
|
||||
|
||||
used = append(used, got)
|
||||
next, err := allocateWireguardAddress(used, "10.0.0.0/24", true)
|
||||
if err != nil {
|
||||
t.Fatalf("allocate after widening: %v", err)
|
||||
}
|
||||
if next != "10.0.1.1/32" {
|
||||
t.Fatalf("second widened address = %q, want 10.0.1.1/32", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateWireguardAddressFillsItsOwnSlash24First(t *testing.T) {
|
||||
got, err := allocateWireguardAddress([]string{"172.16.0.2/32"}, "172.16.0.0/24", true)
|
||||
if err != nil {
|
||||
t.Fatalf("allocateWireguardAddress: %v", err)
|
||||
}
|
||||
if got != "172.16.0.3/32" {
|
||||
t.Fatalf("address = %q, want 172.16.0.3/32 — the inbound's own /24 comes first", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateWireguardAddressWithoutWideningFailsClosed(t *testing.T) {
|
||||
// AmneziaWG's caller passes allowWidening=false: an address outside the
|
||||
// kernel interface's own configured subnet would never actually route,
|
||||
// so exhausting the pool must error instead of silently handing out an
|
||||
// address from a wider scope (unlike plain WireGuard's allowWidening=true).
|
||||
used := make([]string, 0, 254)
|
||||
for i := 2; i <= 255; i++ {
|
||||
used = append(used, fmt.Sprintf("10.0.0.%d/32", i))
|
||||
}
|
||||
if _, err := allocateWireguardAddress(used, "10.0.0.0/24", false); err == nil {
|
||||
t.Fatal("expected an error on a full /24 with widening disabled, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
|
||||
clients := []model.Client{{Email: "x@wg"}, {Email: "y@wg"}}
|
||||
ifaces := []any{map[string]any{"email": "x@wg"}, map[string]any{"email": "y@wg"}}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func streamWithNoiseItem(t *testing.T, item map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
return map[string]any{
|
||||
"network": "tcp",
|
||||
"finalmask": map[string]any{
|
||||
"udp": []any{map[string]any{
|
||||
"type": "noise",
|
||||
"settings": map[string]any{
|
||||
"reset": "60",
|
||||
"noise": []any{item},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func noiseItem(t *testing.T, stream map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
finalmask, _ := stream["finalmask"].(map[string]any)
|
||||
udp, _ := finalmask["udp"].([]any)
|
||||
mask, _ := udp[0].(map[string]any)
|
||||
settings, _ := mask["settings"].(map[string]any)
|
||||
noise, _ := settings["noise"].([]any)
|
||||
item, _ := noise[0].(map[string]any)
|
||||
return item
|
||||
}
|
||||
|
||||
// TestDropEmptyRandPacketsClearsEditorResidue is the regression for the mask
|
||||
// editor writing packet:[] alongside a rand. xray-core counts the empty array
|
||||
// as a packet and refuses the config, which keeps every inbound offline.
|
||||
func TestDropEmptyRandPacketsClearsEditorResidue(t *testing.T) {
|
||||
stream := streamWithNoiseItem(t, map[string]any{
|
||||
"type": "array",
|
||||
"rand": "1-8192",
|
||||
"packet": []any{},
|
||||
"delay": "5",
|
||||
})
|
||||
|
||||
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 1 {
|
||||
t.Fatalf("cleared = %d, want 1", cleared)
|
||||
}
|
||||
item := noiseItem(t, stream)
|
||||
if _, present := item["packet"]; present {
|
||||
t.Fatalf("packet survived: %#v", item)
|
||||
}
|
||||
if item["rand"] != "1-8192" || item["delay"] != "5" {
|
||||
t.Fatalf("healing changed the mask: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropEmptyRandPacketsLeavesRealPacketsAlone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item map[string]any
|
||||
}{
|
||||
{"packet without a rand", map[string]any{"type": "array", "packet": []any{1.0, 2.0}, "rand": 0.0}},
|
||||
{"empty packet without a rand", map[string]any{"type": "array", "packet": []any{}}},
|
||||
{"empty packet with a zero rand", map[string]any{"type": "array", "packet": []any{}, "rand": 0.0}},
|
||||
{"empty packet with a zero range", map[string]any{"type": "array", "packet": []any{}, "rand": "0-0"}},
|
||||
{"string packet", map[string]any{"type": "str", "packet": "ping", "rand": "1-10"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stream := streamWithNoiseItem(t, tt.item)
|
||||
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 {
|
||||
t.Fatalf("cleared = %d, want the item left alone", cleared)
|
||||
}
|
||||
if _, present := noiseItem(t, stream)["packet"]; !present {
|
||||
t.Fatal("packet was dropped")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropEmptyRandPacketsReachesNestedItems covers header-custom, whose items
|
||||
// sit two arrays deep and are subject to the same exclusive-kind rule.
|
||||
func TestDropEmptyRandPacketsReachesNestedItems(t *testing.T) {
|
||||
stream := map[string]any{
|
||||
"finalmask": map[string]any{
|
||||
"tcp": []any{map[string]any{
|
||||
"type": "header-custom",
|
||||
"settings": map[string]any{
|
||||
"clients": []any{[]any{map[string]any{"type": "array", "rand": 64.0, "packet": []any{}}}},
|
||||
"servers": []any{[]any{map[string]any{"type": "array", "rand": 32.0, "packet": []any{}}}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 2 {
|
||||
t.Fatalf("cleared = %d, want 2", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropEmptyRandPacketsIgnoresMissingFinalMask(t *testing.T) {
|
||||
stream := map[string]any{"network": "tcp"}
|
||||
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 {
|
||||
t.Fatalf("cleared = %d, want 0", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealedConfigsBuildInXray closes the loop on both heals: the rows xray
|
||||
// refuses outright must build once the panel has healed them.
|
||||
func TestHealedConfigsBuildInXray(t *testing.T) {
|
||||
t.Run("hysteria v1 row", func(t *testing.T) {
|
||||
in := model.Inbound{
|
||||
Protocol: model.Hysteria,
|
||||
Port: 36715,
|
||||
Listen: "127.0.0.1",
|
||||
Tag: "in-hysteria",
|
||||
Settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
|
||||
StreamSettings: `{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(in.GenXrayInboundConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal generated inbound: %v", err)
|
||||
}
|
||||
var healed map[string]any
|
||||
if err := json.Unmarshal(raw, &healed); err != nil {
|
||||
t.Fatalf("decode generated inbound: %v", err)
|
||||
}
|
||||
assertXrayAccepts(t, "the healed hysteria inbound", buildGoldenInbound(t, healed))
|
||||
|
||||
var unhealed map[string]any
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"tag":"in-hysteria","listen":"127.0.0.1","port":36715,"protocol":"hysteria",
|
||||
"settings":`+in.Settings+`,"streamSettings":`+in.StreamSettings+`}`), &unhealed); err != nil {
|
||||
t.Fatalf("decode raw inbound: %v", err)
|
||||
}
|
||||
if err := buildGoldenInbound(t, unhealed); err == nil {
|
||||
t.Fatal("the unhealed v1 row is expected to be refused; the heal is what makes it buildable")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("noise item with an empty packet", func(t *testing.T) {
|
||||
item := map[string]any{"type": "array", "rand": "1-8192", "packet": []any{}, "delay": "5"}
|
||||
stream := streamWithNoiseItem(t, item)
|
||||
inbound := map[string]any{
|
||||
"tag": "in-vless", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
|
||||
"settings": map[string]any{"clients": []any{}, "decryption": "none"},
|
||||
"streamSettings": stream,
|
||||
}
|
||||
if err := buildGoldenInbound(t, inbound); err == nil {
|
||||
t.Fatal("xray-core is expected to refuse a packet and a rand on one item")
|
||||
}
|
||||
|
||||
dropEmptyRandPackets(stream["finalmask"])
|
||||
inbound["streamSettings"] = stream
|
||||
assertXrayAccepts(t, "the healed noise mask", buildGoldenInbound(t, inbound))
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
@@ -70,7 +71,7 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
|
||||
|
||||
// No global rows: the cross-panel EXISTS branch is skipped (#5392), but a
|
||||
// client over its local quota is still disabled.
|
||||
if got := depletedCond(db); got != depletedClientsCondLocal {
|
||||
if got, _ := depletedCond(db); got != depletedClientsCondLocal {
|
||||
t.Fatalf("empty globals must use the local-only predicate")
|
||||
}
|
||||
seedClientRow(t, "local-cap", 1, 600, 600, 1000)
|
||||
@@ -85,11 +86,77 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
|
||||
if err := svc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: "local-cap", Up: 1, Down: 1}}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||
}
|
||||
if got := depletedCond(db); got != depletedClientsCond {
|
||||
if got, _ := depletedCond(db); got != depletedClientsCond {
|
||||
t.Fatalf("with globals present the cross-panel predicate must be used")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleGlobalTraffic_Ignored(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
staleAt := time.Now().Add(-globalTrafficFreshWindow - time.Hour).UnixMilli()
|
||||
seedStaleGlobal := func(t *testing.T, guid, email string, up, down int64) {
|
||||
t.Helper()
|
||||
if err := svc.AcceptGlobalTraffic(guid, []*xray.ClientTraffic{{Email: email, Up: up, Down: down}}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic(%s): %v", guid, err)
|
||||
}
|
||||
if err := db.Model(&model.ClientGlobalTraffic{}).
|
||||
Where("master_guid = ? AND email = ?", guid, email).
|
||||
Update("updated_at", staleAt).Error; err != nil {
|
||||
t.Fatalf("age row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("stale row alone neither enforces nor selects the cross-panel predicate", func(t *testing.T) {
|
||||
seedClientRow(t, "cap", 1, 100, 100, 1000)
|
||||
seedStaleGlobal(t, "dead-master", "cap", 1000, 900)
|
||||
|
||||
if got, _ := depletedCond(db); got != depletedClientsCondLocal {
|
||||
t.Fatalf("only stale globals must fall back to the local-only predicate")
|
||||
}
|
||||
if _, count, err := svc.disableInvalidClients(db); err != nil {
|
||||
t.Fatalf("disableInvalidClients: %v", err)
|
||||
} else if count != 0 {
|
||||
t.Fatalf("stale global usage must not disable a client, disabled %d", count)
|
||||
}
|
||||
if got := readTraffic(t, db, "cap"); !got.Enable {
|
||||
t.Error("client within its local quota must stay enabled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale row does not inflate the displayed total", func(t *testing.T) {
|
||||
rows := []*xray.ClientTraffic{{Email: "cap", Up: 100, Down: 100}}
|
||||
overlayGlobalTraffic(db, rows)
|
||||
if rows[0].Up != 100 || rows[0].Down != 100 {
|
||||
t.Errorf("stale global must not overlay display counters, got up=%d down=%d", rows[0].Up, rows[0].Down)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a live master still enforces alongside the stale row", func(t *testing.T) {
|
||||
if err := svc.AcceptGlobalTraffic("live-master", []*xray.ClientTraffic{{Email: "cap", Up: 1, Down: 1}}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||
}
|
||||
if got, _ := depletedCond(db); got != depletedClientsCond {
|
||||
t.Fatalf("a fresh global row must select the cross-panel predicate")
|
||||
}
|
||||
if _, count, err := svc.disableInvalidClients(db); err != nil {
|
||||
t.Fatalf("disableInvalidClients: %v", err)
|
||||
} else if count != 0 {
|
||||
t.Fatalf("the live master reports usage well under quota, disabled %d", count)
|
||||
}
|
||||
|
||||
if err := svc.AcceptGlobalTraffic("live-master", []*xray.ClientTraffic{{Email: "cap", Up: 600, Down: 500}}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||
}
|
||||
if _, count, err := svc.disableInvalidClients(db); err != nil {
|
||||
t.Fatalf("disableInvalidClients: %v", err)
|
||||
} else if count != 1 {
|
||||
t.Fatalf("fresh cross-panel depletion must disable the client, disabled %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGlobalUsage_DisablesClient(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"maps"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"github.com/xtls/xray-core/infra/conf"
|
||||
)
|
||||
|
||||
// The frontend's golden fixtures are the panel's model of an xray config: the
|
||||
// Zod snapshots pin what the forms parse and emit. Parsing proves the panel
|
||||
// agrees with itself, not that xray-core would accept the result, so every
|
||||
// fixture is also built here through the very config builders the panel hands
|
||||
// its config to — conf.InboundDetourConfig for the full-config and AddInbound
|
||||
// paths, conf.RouterConfig for ApplyRoutingConfig, conf.DNSConfig for the dns
|
||||
// section. A fixture xray-core refuses is a config the panel would let an
|
||||
// admin save and then fail to start the core with, taking every inbound down.
|
||||
//
|
||||
// mtproto is excluded: it is served by the bundled mtg-multi sidecar, not by
|
||||
// xray, so xray-core has no config id for it.
|
||||
|
||||
func goldenFixtureDir(t *testing.T, category string) string {
|
||||
t.Helper()
|
||||
dir, err := filepath.Abs(filepath.Join("..", "..", "..", "frontend", "src", "test", "golden", "fixtures", category))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve fixture dir: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// goldenFixtures returns every fixture in a category as name -> decoded object.
|
||||
func goldenFixtures(t *testing.T, category string) map[string]map[string]any {
|
||||
t.Helper()
|
||||
dir := goldenFixtureDir(t, category)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", dir, err)
|
||||
}
|
||||
out := make(map[string]map[string]any)
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
if len(names) == 0 {
|
||||
t.Fatalf("no fixtures under %s", dir)
|
||||
}
|
||||
for _, name := range names {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", name, err)
|
||||
}
|
||||
out[strings.TrimSuffix(name, ".json")] = obj
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeTestCertificate writes a self-signed certificate and key, returning both
|
||||
// paths. The TLS fixtures point certificateFile/keyFile at deployment paths
|
||||
// that do not exist here, and xray-core reads them while building.
|
||||
func writeTestCertificate(t *testing.T) (certPath, keyPath string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "golden-fixture.test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
DNSNames: []string{"golden-fixture.test"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal key: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
certPath = filepath.Join(dir, "fixture.crt")
|
||||
keyPath = filepath.Join(dir, "fixture.key")
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
|
||||
t.Fatalf("write certificate: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
return certPath, keyPath
|
||||
}
|
||||
|
||||
// repointCertificateFiles rewrites every certificateFile/keyFile reference to
|
||||
// the generated pair, so a fixture is judged on its shape rather than on paths
|
||||
// that only exist on a deployed server.
|
||||
func repointCertificateFiles(node any, certPath, keyPath string) {
|
||||
switch value := node.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range value {
|
||||
switch key {
|
||||
case "certificateFile":
|
||||
if _, ok := child.(string); ok {
|
||||
value[key] = certPath
|
||||
continue
|
||||
}
|
||||
case "keyFile":
|
||||
if _, ok := child.(string); ok {
|
||||
value[key] = keyPath
|
||||
continue
|
||||
}
|
||||
}
|
||||
repointCertificateFiles(child, certPath, keyPath)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range value {
|
||||
repointCertificateFiles(child, certPath, keyPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertXrayAccepts fails unless xray-core built the fixture. Fixtures naming
|
||||
// geoip:/geosite: need the dat files the panel ships next to the xray binary;
|
||||
// where they are absent the loader fails on the missing file rather than on the
|
||||
// fixture, so those are skipped instead of reported as broken.
|
||||
func assertXrayAccepts(t *testing.T, subject string, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if isMissingGeoAssetErr(err) {
|
||||
t.Skipf("geo data files not available, cannot judge %s: %v", subject, err)
|
||||
}
|
||||
t.Fatalf("xray-core refuses %s: %v", subject, err)
|
||||
}
|
||||
|
||||
func buildGoldenInbound(t *testing.T, inbound map[string]any) error {
|
||||
t.Helper()
|
||||
certPath, keyPath := writeTestCertificate(t)
|
||||
repointCertificateFiles(inbound, certPath, keyPath)
|
||||
|
||||
raw, err := json.Marshal(inbound)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal inbound: %v", err)
|
||||
}
|
||||
detour := new(conf.InboundDetourConfig)
|
||||
if err := json.Unmarshal(raw, detour); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = detour.Build()
|
||||
return err
|
||||
}
|
||||
|
||||
// TestGoldenInboundFixturesBuildInXray wraps each protocol fixture in a minimal
|
||||
// inbound and builds it.
|
||||
func TestGoldenInboundFixturesBuildInXray(t *testing.T) {
|
||||
for name, fixture := range goldenFixtures(t, "inbound") {
|
||||
if protocol, _ := fixture["protocol"].(string); protocol == string(model.MTProto) {
|
||||
continue
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
inbound := map[string]any{
|
||||
"tag": "golden-in",
|
||||
"listen": "127.0.0.1",
|
||||
"port": 8443,
|
||||
"protocol": fixture["protocol"],
|
||||
"settings": fixture["settings"],
|
||||
}
|
||||
assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenInboundFullFixturesBuildInXray runs the complete panel Inbound
|
||||
// model through GenXrayInboundConfig, the conversion both the full-config and
|
||||
// the live AddInbound paths use, and builds the result.
|
||||
func TestGoldenInboundFullFixturesBuildInXray(t *testing.T) {
|
||||
for name, fixture := range goldenFixtures(t, "inbound-full") {
|
||||
protocol, _ := fixture["protocol"].(string)
|
||||
if protocol == string(model.MTProto) {
|
||||
continue
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
section := func(key string) string {
|
||||
value, ok := fixture[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s: %v", key, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
port := 0
|
||||
if p, ok := fixture["port"].(float64); ok {
|
||||
port = int(p)
|
||||
}
|
||||
listen, _ := fixture["listen"].(string)
|
||||
tag, _ := fixture["tag"].(string)
|
||||
|
||||
ib := &model.Inbound{
|
||||
Protocol: model.Protocol(protocol),
|
||||
Port: port,
|
||||
Listen: listen,
|
||||
Tag: tag,
|
||||
Settings: section("settings"),
|
||||
StreamSettings: section("streamSettings"),
|
||||
Sniffing: section("sniffing"),
|
||||
}
|
||||
raw, err := json.Marshal(ib.GenXrayInboundConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal generated inbound: %v", err)
|
||||
}
|
||||
var generated map[string]any
|
||||
if err := json.Unmarshal(raw, &generated); err != nil {
|
||||
t.Fatalf("decode generated inbound: %v", err)
|
||||
}
|
||||
assertXrayAccepts(t, "the generated inbound", buildGoldenInbound(t, generated))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenStreamFixturesBuildInXray attaches the transport fragments — whole
|
||||
// stream sections, the security block, sockopt and finalmask — to an inbound.
|
||||
func TestGoldenStreamFixturesBuildInXray(t *testing.T) {
|
||||
for _, category := range []string{"stream", "security", "sockopt", "finalmask"} {
|
||||
for name, fixture := range goldenFixtures(t, category) {
|
||||
t.Run(category+"/"+name, func(t *testing.T) {
|
||||
stream := map[string]any{"network": "tcp"}
|
||||
switch category {
|
||||
case "stream":
|
||||
stream = fixture
|
||||
case "security":
|
||||
maps.Copy(stream, fixture)
|
||||
case "sockopt":
|
||||
stream["sockopt"] = fixture
|
||||
case "finalmask":
|
||||
stream["finalmask"] = fixture
|
||||
}
|
||||
inbound := map[string]any{
|
||||
"tag": "golden-in", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
|
||||
"settings": map[string]any{
|
||||
"clients": []any{map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "golden"}},
|
||||
"decryption": "none",
|
||||
},
|
||||
"streamSettings": stream,
|
||||
}
|
||||
assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildGoldenRouting(routing map[string]any) error {
|
||||
raw, err := json.Marshal(routing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
router := new(conf.RouterConfig)
|
||||
if err := json.Unmarshal(raw, router); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = router.Build()
|
||||
return err
|
||||
}
|
||||
|
||||
// TestGoldenRoutingFixturesBuildInXray builds the rule and balancer fixtures
|
||||
// through the router config ApplyRoutingConfig hands to the running core.
|
||||
func TestGoldenRoutingFixturesBuildInXray(t *testing.T) {
|
||||
for name, rule := range goldenFixtures(t, "rule") {
|
||||
t.Run("rule/"+name, func(t *testing.T) {
|
||||
assertXrayAccepts(t, "this rule", buildGoldenRouting(map[string]any{
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": []any{rule},
|
||||
"balancers": []any{map[string]any{"tag": "balancer-load", "selector": []any{"proxy-"}}},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
for name, balancer := range goldenFixtures(t, "balancer") {
|
||||
t.Run("balancer/"+name, func(t *testing.T) {
|
||||
assertXrayAccepts(t, "this balancer", buildGoldenRouting(map[string]any{
|
||||
"domainStrategy": "AsIs",
|
||||
"balancers": []any{balancer},
|
||||
"rules": []any{map[string]any{
|
||||
"type": "field", "port": "443", "balancerTag": balancer["tag"],
|
||||
}},
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenDNSFixturesBuildInXray builds the dns section, and each dns-server
|
||||
// fixture inside one.
|
||||
func TestGoldenDNSFixturesBuildInXray(t *testing.T) {
|
||||
build := func(dns map[string]any) error {
|
||||
raw, err := json.Marshal(dns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dnsConf := new(conf.DNSConfig)
|
||||
if err := json.Unmarshal(raw, dnsConf); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dnsConf.Build()
|
||||
return err
|
||||
}
|
||||
|
||||
for name, fixture := range goldenFixtures(t, "dns") {
|
||||
t.Run("dns/"+name, func(t *testing.T) {
|
||||
assertXrayAccepts(t, "this dns section", build(fixture))
|
||||
})
|
||||
}
|
||||
for name, server := range goldenFixtures(t, "dns-server") {
|
||||
t.Run("dns-server/"+name, func(t *testing.T) {
|
||||
assertXrayAccepts(t, "this dns server", build(map[string]any{"servers": []any{server}}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func isMissingGeoAssetErr(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "geoip.dat") || strings.Contains(msg, "geosite.dat")
|
||||
}
|
||||
+272
-57
@@ -8,10 +8,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"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"
|
||||
@@ -32,6 +35,13 @@ type InboundService struct {
|
||||
fallbackService FallbackService
|
||||
}
|
||||
|
||||
func normalizeTrafficResetDay(day int) int {
|
||||
if day < 1 {
|
||||
return 1
|
||||
}
|
||||
return min(day, 31)
|
||||
}
|
||||
|
||||
func normalizeInboundShareAddrStrategy(strategy string) string {
|
||||
strategy = strings.TrimSpace(strategy)
|
||||
switch strategy {
|
||||
@@ -618,6 +628,188 @@ func validateFinalMaskRealityCombo(streamSettings string) error {
|
||||
return common.NewError("Finalmask is not supported with REALITY security — it crashes Xray-core on the first connection (see XTLS/Xray-core#6453). Remove the finalmask configuration or switch security to tls/none.")
|
||||
}
|
||||
|
||||
var xmcProfileUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{3,16}$`)
|
||||
|
||||
// xmcMaskProfilesComplete reports whether an xmc finalmask carries the signed
|
||||
// Minecraft session profiles xray-core has required since v26.7.28 (#6487).
|
||||
// The core replaced the old `usernames` string list with `profiles` objects
|
||||
// and removed the "default to Dream when empty" fallback, so a mask still on
|
||||
// the legacy shape — or one whose profiles are incomplete — now fails
|
||||
// conf.XMC.Build() and takes the entire config down with it rather than
|
||||
// degrading that one inbound.
|
||||
//
|
||||
// The texture fields are a signed blob only Mojang's session server can issue
|
||||
// (resolve the UUID by username, then fetch the profile with unsigned=false),
|
||||
// so the panel cannot synthesize a valid profile from a legacy username; an
|
||||
// incomplete mask can only be reported or dropped.
|
||||
func xmcMaskProfilesComplete(mask map[string]any) bool {
|
||||
settings, ok := mask["settings"].(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
profiles, _ := settings["profiles"].([]any)
|
||||
if len(profiles) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, entry := range profiles {
|
||||
profile, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
username, _ := profile["username"].(string)
|
||||
if !xmcProfileUsernamePattern.MatchString(username) {
|
||||
return false
|
||||
}
|
||||
id, _ := profile["uuid"].(string)
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return false
|
||||
}
|
||||
if value, _ := profile["texturesValue"].(string); value == "" {
|
||||
return false
|
||||
}
|
||||
if signature, _ := profile["texturesSignature"].(string); signature == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isIncompleteXmcMask reports whether a finalmask.tcp entry is an xmc mask
|
||||
// xray-core would refuse to build.
|
||||
func isIncompleteXmcMask(entry any) bool {
|
||||
mask, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if maskType, _ := mask["type"].(string); maskType != "xmc" {
|
||||
return false
|
||||
}
|
||||
return !xmcMaskProfilesComplete(mask)
|
||||
}
|
||||
|
||||
// incompleteXmcMaskCount counts the stream's xmc finalmask entries that
|
||||
// xray-core would refuse to build.
|
||||
func incompleteXmcMaskCount(stream map[string]any) int {
|
||||
finalmask, ok := stream["finalmask"].(map[string]any)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
tcp, _ := finalmask["tcp"].([]any)
|
||||
count := 0
|
||||
for _, entry := range tcp {
|
||||
if isIncompleteXmcMask(entry) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// stripIncompleteXmcMasks removes every xmc finalmask entry xray-core would
|
||||
// refuse to build, returning how many were dropped, and clears the finalmask
|
||||
// object once nothing is left in it.
|
||||
//
|
||||
// AddInbound and UpdateInbound reject an incomplete mask at save time, but a
|
||||
// row that never went through those paths — an upgrade from a panel predating
|
||||
// v26.7.28, node sync, a restored backup, a direct DB edit — would otherwise
|
||||
// fail the whole config build and keep every other inbound offline too.
|
||||
// Dropping only the offending mask degrades that one inbound instead, which
|
||||
// the accompanying warning tells the admin to reconfigure.
|
||||
func stripIncompleteXmcMasks(stream map[string]any) int {
|
||||
finalmask, ok := stream["finalmask"].(map[string]any)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
tcp, _ := finalmask["tcp"].([]any)
|
||||
if len(tcp) == 0 {
|
||||
return 0
|
||||
}
|
||||
kept := make([]any, 0, len(tcp))
|
||||
dropped := 0
|
||||
for _, entry := range tcp {
|
||||
if isIncompleteXmcMask(entry) {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, entry)
|
||||
}
|
||||
if dropped == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(finalmask, "tcp")
|
||||
} else {
|
||||
finalmask["tcp"] = kept
|
||||
}
|
||||
if len(finalmask) == 0 {
|
||||
delete(stream, "finalmask")
|
||||
}
|
||||
return dropped
|
||||
}
|
||||
|
||||
// dropEmptyRandPackets removes the leftover empty "packet" from finalmask
|
||||
// items that also carry a rand, and reports how many it cleared.
|
||||
//
|
||||
// xray-core treats even an empty array as a packet, and every item kind is
|
||||
// exclusive: noise refuses "len(item.Packet) > 0 && item.Rand.To > 0" and
|
||||
// header-custom refuses "exactly one item kind must be set". Either error
|
||||
// fails the whole config build, so one such item keeps every inbound offline.
|
||||
// The panel's mask editor wrote that pair whenever an item was switched to the
|
||||
// rand-driven array kind, so stored rows carry it; clearing an empty packet
|
||||
// changes nothing about the mask the admin configured.
|
||||
func dropEmptyRandPackets(node any) int {
|
||||
switch value := node.(type) {
|
||||
case map[string]any:
|
||||
cleared := 0
|
||||
if packet, ok := value["packet"].([]any); ok && len(packet) == 0 && randIsSet(value["rand"]) {
|
||||
delete(value, "packet")
|
||||
cleared++
|
||||
}
|
||||
for _, child := range value {
|
||||
cleared += dropEmptyRandPackets(child)
|
||||
}
|
||||
return cleared
|
||||
case []any:
|
||||
cleared := 0
|
||||
for _, child := range value {
|
||||
cleared += dropEmptyRandPackets(child)
|
||||
}
|
||||
return cleared
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// randIsSet reports whether a finalmask item's rand selects a random packet.
|
||||
// It is a number on header-custom items and a dash-range string on noise ones.
|
||||
func randIsSet(value any) bool {
|
||||
switch rand := value.(type) {
|
||||
case float64:
|
||||
return rand > 0
|
||||
case string:
|
||||
return rand != "" && rand != "0" && rand != "0-0"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// validateFinalMaskXmcProfiles rejects an xmc finalmask without complete
|
||||
// profiles at save time, so the admin gets a targeted error instead of a core
|
||||
// that refuses to start (or, after GetXrayConfig heals it, an inbound quietly
|
||||
// serving without the obfuscation they configured).
|
||||
func validateFinalMaskXmcProfiles(streamSettings string) error {
|
||||
if streamSettings == "" {
|
||||
return nil
|
||||
}
|
||||
var stream map[string]any
|
||||
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
|
||||
return nil
|
||||
}
|
||||
if incompleteXmcMaskCount(stream) == 0 {
|
||||
return nil
|
||||
}
|
||||
return common.NewError("XMC finalmask requires at least one complete Minecraft profile — each needs a username (3-16 of A-Z a-z 0-9 _), a UUID, and both texture fields from Mojang's session server (XTLS/Xray-core#6487). Complete the profiles or remove the XMC mask.")
|
||||
}
|
||||
|
||||
// normalizeMtprotoSecret rebuilds every mtproto client's FakeTLS secret so it is
|
||||
// always valid before the row is persisted, and drops the vestigial inbound-level
|
||||
// secret and adTag: MTProto is multi-client, so mtg and every share link read
|
||||
@@ -746,11 +938,15 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
|
||||
// Returns the created inbound, whether Xray needs restart, and any error.
|
||||
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||
inbound.Id = 0
|
||||
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
||||
// Normalize streamSettings based on protocol
|
||||
s.normalizeStreamSettings(inbound)
|
||||
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
s.normalizeMtprotoSecret(inbound)
|
||||
if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
|
||||
return inbound, false, err
|
||||
@@ -914,7 +1110,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
payload := inbound
|
||||
pushable := true
|
||||
if inbound.Protocol == model.MTProto {
|
||||
if built, bErr := s.buildRuntimeInboundForAPI(tx, inbound); bErr == nil {
|
||||
if built, bErr := s.buildInboundForLocalRuntime(tx, inbound); bErr == nil {
|
||||
payload = built
|
||||
} else {
|
||||
logger.Debug("Unable to prepare runtime inbound config:", bErr)
|
||||
@@ -1162,7 +1358,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
|
||||
runtimeInbound, err := s.buildInboundForLocalRuntime(db, inbound)
|
||||
if err != nil {
|
||||
logger.Debug("SetInboundEnable: build runtime config failed:", err)
|
||||
return true, nil
|
||||
@@ -1175,11 +1371,15 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
}
|
||||
|
||||
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
||||
// Normalize streamSettings based on protocol
|
||||
s.normalizeStreamSettings(inbound)
|
||||
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
s.normalizeMtprotoSecret(inbound)
|
||||
if err := s.normalizeAmneziaWGSettings(inbound); err != nil {
|
||||
return inbound, false, err
|
||||
@@ -1307,6 +1507,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
oldInbound.Enable = inbound.Enable
|
||||
oldInbound.ExpiryTime = inbound.ExpiryTime
|
||||
oldInbound.TrafficReset = inbound.TrafficReset
|
||||
oldInbound.TrafficResetDay = inbound.TrafficResetDay
|
||||
oldInbound.Listen = inbound.Listen
|
||||
oldInbound.Port = inbound.Port
|
||||
oldInbound.Protocol = inbound.Protocol
|
||||
@@ -1348,7 +1549,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
payload := oldInbound
|
||||
pushable := true
|
||||
if inbound.Enable {
|
||||
if built, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound); err2 == nil {
|
||||
if built, err2 := s.buildInboundForLocalRuntime(tx, oldInbound); err2 == nil {
|
||||
payload = built
|
||||
} else {
|
||||
logger.Debug("Unable to prepare runtime inbound config:", err2)
|
||||
@@ -1374,7 +1575,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
var runtimeInbound *model.Inbound
|
||||
if inbound.Enable {
|
||||
var err2 error
|
||||
runtimeInbound, err2 = s.buildRuntimeInboundForAPI(tx, oldInbound)
|
||||
runtimeInbound, err2 = s.buildInboundForLocalRuntime(tx, oldInbound)
|
||||
if err2 != nil {
|
||||
logger.Debug("Unable to prepare runtime inbound config:", err2)
|
||||
needRestart = true
|
||||
@@ -1445,81 +1646,95 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
return inbound, needRestart, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
|
||||
// A node mirrors this payload into its own DB, so every client must survive:
|
||||
// filtering one out makes the node delete it, and the master then mirrors that.
|
||||
func (s *InboundService) buildInboundForNodePush(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
|
||||
if inbound == nil {
|
||||
return nil, fmt.Errorf("inbound is nil")
|
||||
}
|
||||
|
||||
runtimeInbound := *inbound
|
||||
built := *inbound
|
||||
settings := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mutated := false
|
||||
if clients, ok := settings["clients"].([]any); ok {
|
||||
var clientStats []xray.ClientTraffic
|
||||
err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("inbound_id = ?", inbound.Id).
|
||||
Select("email", "enable").
|
||||
Find(&clientStats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !inboundCanHostFallbacks(inbound) {
|
||||
return &built, nil
|
||||
}
|
||||
fallbacks, err := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fallbacks) == 0 {
|
||||
return &built, nil
|
||||
}
|
||||
generic := make([]any, 0, len(fallbacks))
|
||||
for _, f := range fallbacks {
|
||||
generic = append(generic, f)
|
||||
}
|
||||
settings["fallbacks"] = generic
|
||||
|
||||
enableMap := make(map[string]bool, len(clientStats))
|
||||
for _, clientTraffic := range clientStats {
|
||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||
}
|
||||
modifiedSettings, mErr := json.MarshalIndent(settings, "", " ")
|
||||
if mErr != nil {
|
||||
return nil, mErr
|
||||
}
|
||||
built.Settings = string(modifiedSettings)
|
||||
return &built, nil
|
||||
}
|
||||
|
||||
finalClients := make([]any, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
c, ok := client.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
email, _ := c["email"].(string)
|
||||
if enable, exists := enableMap[email]; exists && !enable {
|
||||
continue
|
||||
}
|
||||
|
||||
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
|
||||
continue
|
||||
}
|
||||
|
||||
finalClients = append(finalClients, c)
|
||||
}
|
||||
|
||||
settings["clients"] = finalClients
|
||||
mutated = true
|
||||
// Strips disabled clients on top of the node payload. Safe only because the
|
||||
// target here is an in-memory Xray/mtg config, not another panel's database.
|
||||
func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
|
||||
built, err := s.buildInboundForNodePush(tx, inbound)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if inboundCanHostFallbacks(inbound) {
|
||||
fallbacks, fbErr := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
|
||||
if fbErr != nil {
|
||||
return nil, fbErr
|
||||
}
|
||||
if len(fallbacks) > 0 {
|
||||
generic := make([]any, 0, len(fallbacks))
|
||||
for _, f := range fallbacks {
|
||||
generic = append(generic, f)
|
||||
}
|
||||
settings["fallbacks"] = generic
|
||||
mutated = true
|
||||
}
|
||||
settings := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(built.Settings), &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
return built, nil
|
||||
}
|
||||
|
||||
if !mutated {
|
||||
return &runtimeInbound, 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
|
||||
}
|
||||
enableMap := make(map[string]bool, len(clientStats))
|
||||
for _, clientTraffic := range clientStats {
|
||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||
}
|
||||
|
||||
finalClients := make([]any, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
c, ok := client.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
email, _ := c["email"].(string)
|
||||
if enable, exists := enableMap[email]; exists && !enable {
|
||||
continue
|
||||
}
|
||||
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
|
||||
continue
|
||||
}
|
||||
finalClients = append(finalClients, c)
|
||||
}
|
||||
settings["clients"] = finalClients
|
||||
|
||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeInbound := *built
|
||||
runtimeInbound.Settings = string(modifiedSettings)
|
||||
|
||||
return &runtimeInbound, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestAPIUserFromClientCarriesShadowsocksCipher pins what the renew path must
|
||||
// hand the runtime API. A shadowsocks client object holds no cipher of its own,
|
||||
// and xray's legacy and 2022 inbounds take different account types that they
|
||||
// cast without checking — an account built for the wrong one panics the core.
|
||||
func TestAPIUserFromClientCarriesShadowsocksCipher(t *testing.T) {
|
||||
client := map[string]any{"email": "a@x", "password": "pw", "method": "aes-256-gcm"}
|
||||
|
||||
user := apiUserFromClient(client, "aes-256-gcm")
|
||||
if got := user["cipher"]; got != "aes-256-gcm" {
|
||||
t.Fatalf("cipher = %v, want the inbound method", got)
|
||||
}
|
||||
if _, polluted := client["cipher"]; polluted {
|
||||
t.Fatal("the stored client object was mutated; the API-only cipher would be persisted into the inbound settings")
|
||||
}
|
||||
|
||||
user["email"] = "changed@x"
|
||||
if client["email"] != "a@x" {
|
||||
t.Fatal("the API user shares storage with the stored client object")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIUserFromClientWithoutCipher(t *testing.T) {
|
||||
client := map[string]any{"email": "a@x", "id": "11111111-1111-1111-1111-111111111111"}
|
||||
user := apiUserFromClient(client, "")
|
||||
if _, ok := user["cipher"]; ok {
|
||||
t.Fatal("a non-shadowsocks client must not gain a cipher key")
|
||||
}
|
||||
if user["id"] != client["id"] {
|
||||
t.Fatalf("id = %v, want it copied from the client", user["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoRenewShadowsocksKeepsSettingsClean renews a shadowsocks client and
|
||||
// checks the inbound settings the panel writes back: the cipher the API needs
|
||||
// must not leak into a stored client object, where it would end up in the
|
||||
// generated xray config as a per-user key.
|
||||
func TestAutoRenewShadowsocksKeepsSettingsClean(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &InboundService{}
|
||||
db := database.GetDB()
|
||||
|
||||
past := time.Now().Add(-48 * time.Hour).UnixMilli()
|
||||
clients := []model.Client{
|
||||
{Email: "ss@x", Password: "pw", Enable: false, Reset: 30, ExpiryTime: past},
|
||||
}
|
||||
|
||||
settings := map[string]any{
|
||||
"method": "aes-256-gcm",
|
||||
"network": "tcp,udp",
|
||||
"clients": []map[string]any{
|
||||
{"email": "ss@x", "password": "pw", "method": "aes-256-gcm", "enable": false, "expiryTime": past, "reset": 30},
|
||||
},
|
||||
}
|
||||
raw, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ib := mkInbound(t, 30011, model.Shadowsocks, string(raw))
|
||||
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("SyncInbound: %v", err)
|
||||
}
|
||||
if err := db.Create(&[]xray.ClientTraffic{
|
||||
{InboundId: ib.Id, Email: "ss@x", Enable: false, Up: 10, Down: 20, Reset: 30, ExpiryTime: past},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed client_traffics: %v", err)
|
||||
}
|
||||
|
||||
if _, count, err := svc.autoRenewClients(db); err != nil {
|
||||
t.Fatalf("autoRenewClients: %v", err)
|
||||
} else if count != 1 {
|
||||
t.Fatalf("renewed count = %d, want 1", count)
|
||||
}
|
||||
|
||||
var stored model.Inbound
|
||||
if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil {
|
||||
t.Fatalf("read inbound: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(stored.Settings), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal stored settings: %v", err)
|
||||
}
|
||||
storedClients, _ := parsed["clients"].([]any)
|
||||
if len(storedClients) != 1 {
|
||||
t.Fatalf("stored clients = %d, want 1", len(storedClients))
|
||||
}
|
||||
client, _ := storedClients[0].(map[string]any)
|
||||
if _, polluted := client["cipher"]; polluted {
|
||||
t.Fatalf("the renewed client was persisted with an API-only cipher key: %+v", client)
|
||||
}
|
||||
if client["method"] != "aes-256-gcm" {
|
||||
t.Fatalf("the client's method was lost: %+v", client)
|
||||
}
|
||||
if enabled, _ := client["enable"].(bool); !enabled {
|
||||
t.Fatalf("the renewed client was not re-enabled: %+v", client)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
|
||||
now := time.Now().Unix() * 1000
|
||||
needRestart := false
|
||||
|
||||
if p != nil {
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
var tags []string
|
||||
err := tx.Table("inbounds").
|
||||
Select("inbounds.tag").
|
||||
@@ -28,7 +28,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
_ = s.xrayApi.Init(p.GetAPIPort())
|
||||
_ = s.xrayApi.Init(process.GetAPIPort())
|
||||
for _, tag := range tags {
|
||||
err1 := s.xrayApi.DelInbound(tag)
|
||||
if err1 == nil {
|
||||
@@ -49,46 +49,58 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
|
||||
return needRestart, count, err
|
||||
}
|
||||
|
||||
const globalTrafficFreshWindow = 24 * time.Hour
|
||||
|
||||
func globalTrafficFreshSince() int64 {
|
||||
return time.Now().Add(-globalTrafficFreshWindow).UnixMilli()
|
||||
}
|
||||
|
||||
// depletedClientsCond matches clients that exhausted their quota or expired.
|
||||
// Besides the local counters it also trips on the cross-panel usage a master
|
||||
// pushed into client_global_traffics — that's what lets a node cut a client
|
||||
// whose combined usage exceeds the quota even though the local share doesn't
|
||||
// (placeholders: now).
|
||||
// whose combined usage exceeds the quota even though the local share doesn't.
|
||||
// Only rows a master refreshed recently count (placeholders: now, freshSince).
|
||||
const depletedClientsCond = `((total > 0 AND up + down >= total)
|
||||
OR (expiry_time > 0 AND expiry_time <= ?)
|
||||
OR (total > 0 AND EXISTS (
|
||||
SELECT 1 FROM client_global_traffics g
|
||||
WHERE g.email = client_traffics.email AND g.up + g.down >= client_traffics.total
|
||||
WHERE g.email = client_traffics.email
|
||||
AND g.updated_at >= ?
|
||||
AND g.up + g.down >= client_traffics.total
|
||||
)))`
|
||||
|
||||
// depletedClientsCondLocal is depletedClientsCond without the cross-panel
|
||||
// client_global_traffics check. The EXISTS branch is a correlated subquery that
|
||||
// turns every traffic poll into a full client_traffics scan; on a panel no
|
||||
// master pushes to (the common case) client_global_traffics is empty, so the
|
||||
// branch can never match and is pure CPU cost (#5392).
|
||||
// branch can never match and is pure CPU cost (#5392). Placeholders: now.
|
||||
const depletedClientsCondLocal = `((total > 0 AND up + down >= total)
|
||||
OR (expiry_time > 0 AND expiry_time <= ?))`
|
||||
|
||||
// depletedCond returns the local-only predicate unless this panel actually
|
||||
// holds global-traffic rows, in which case the cross-panel EXISTS check is
|
||||
// needed to enforce combined quota. Both variants take the same single
|
||||
// expiry_time placeholder, so callers pass identical args either way.
|
||||
func depletedCond(tx *gorm.DB) string {
|
||||
// depletedCond returns the predicate matching depleted clients together with
|
||||
// the arguments it binds. The local-only variant is used unless this panel
|
||||
// holds a global-traffic row a master still refreshes, in which case the
|
||||
// cross-panel EXISTS check is needed to enforce combined quota.
|
||||
func depletedCond(tx *gorm.DB) (string, []any) {
|
||||
now := time.Now().UnixMilli()
|
||||
freshSince := globalTrafficFreshSince()
|
||||
var probe int64
|
||||
if err := tx.Model(&model.ClientGlobalTraffic{}).Limit(1).Count(&probe).Error; err == nil && probe > 0 {
|
||||
return depletedClientsCond
|
||||
err := tx.Model(&model.ClientGlobalTraffic{}).
|
||||
Where("updated_at >= ?", freshSince).
|
||||
Limit(1).Count(&probe).Error
|
||||
if err == nil && probe > 0 {
|
||||
return depletedClientsCond, []any{now, freshSince}
|
||||
}
|
||||
return depletedClientsCondLocal
|
||||
return depletedClientsCondLocal, []any{now}
|
||||
}
|
||||
|
||||
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
|
||||
now := time.Now().Unix() * 1000
|
||||
needRestart := false
|
||||
cond := depletedCond(tx)
|
||||
cond, condArgs := depletedCond(tx)
|
||||
|
||||
var depletedRows []xray.ClientTraffic
|
||||
err := tx.Model(xray.ClientTraffic{}).
|
||||
Where(cond+" AND enable = ?", now, true).
|
||||
Where(cond+" AND enable = ?", append(condArgs, true)...).
|
||||
Find(&depletedRows).Error
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
@@ -141,8 +153,8 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
||||
}
|
||||
}
|
||||
|
||||
if p != nil && len(localTargets) > 0 {
|
||||
_ = s.xrayApi.Init(p.GetAPIPort())
|
||||
if process := currentXrayProcess(); process != nil && len(localTargets) > 0 {
|
||||
_ = s.xrayApi.Init(process.GetAPIPort())
|
||||
for _, t := range localTargets {
|
||||
err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
|
||||
if err1 == nil {
|
||||
@@ -185,7 +197,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
||||
if len(depletedEmails) > 0 {
|
||||
if err := tx.Model(&model.ClientRecord{}).
|
||||
Where("email IN ?", depletedEmails).
|
||||
Updates(map[string]any{"enable": false, "updated_at": now}).Error; err != nil {
|
||||
Updates(map[string]any{"enable": false, "updated_at": time.Now().UnixMilli()}).Error; err != nil {
|
||||
logger.Warning("disableInvalidClients update clients.enable:", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ func TestBuildRuntimeInboundForAPI_InjectsFallbacks(t *testing.T) {
|
||||
t.Fatalf("seed fallback: %v", err)
|
||||
}
|
||||
|
||||
runtimeIb, err := svc.buildRuntimeInboundForAPI(db, master)
|
||||
runtimeIb, err := svc.buildInboundForLocalRuntime(db, master)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
|
||||
t.Fatalf("buildInboundForLocalRuntime: %v", err)
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
@@ -68,9 +68,9 @@ func TestBuildRuntimeInboundForAPI_NoFallbacksOnWsInbound(t *testing.T) {
|
||||
t.Fatalf("seed fallback: %v", err)
|
||||
}
|
||||
|
||||
runtimeIb, err := svc.buildRuntimeInboundForAPI(db, ib)
|
||||
runtimeIb, err := svc.buildInboundForLocalRuntime(db, ib)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
|
||||
t.Fatalf("buildInboundForLocalRuntime: %v", err)
|
||||
}
|
||||
if strings.Contains(runtimeIb.Settings, "fallbacks") {
|
||||
t.Fatalf("ws inbound must not receive fallbacks: %s", runtimeIb.Settings)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/xtls/xray-core/infra/conf"
|
||||
)
|
||||
|
||||
const completeXmcProfile = `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`
|
||||
|
||||
func TestValidateFinalMaskXmcProfiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
streamSettings string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty streamSettings",
|
||||
streamSettings: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no finalmask",
|
||||
streamSettings: `{"network":"tcp","security":"none"}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "non-xmc mask is untouched",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "xmc with a complete profile",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "legacy usernames shape without profiles",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","usernames":["Dream"]}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "xmc with an empty profiles array",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[]}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "profile missing the textures signature",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":""}]}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "profile with an unparseable uuid",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"not-a-uuid","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "profile with an out-of-range username",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"ab","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "one complete and one incomplete profile",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `,{"username":"Herobrine","uuid":"","texturesValue":"","texturesSignature":""}]}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateFinalMaskXmcProfiles(tt.streamSettings)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateFinalMaskXmcProfiles(%q) error = %v, wantErr %v", tt.streamSettings, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestXmcMaskProfilesCompleteMatchesCoreValidation pins the panel's predicate
|
||||
// to xray-core's own loader instead of a restatement of it: every profile the
|
||||
// panel accepts must build, and every one it rejects must fail to build. A
|
||||
// future core release that tightens or relaxes the rules fails here rather
|
||||
// than silently producing configs the core refuses to start on.
|
||||
func TestXmcMaskProfilesCompleteMatchesCoreValidation(t *testing.T) {
|
||||
profiles := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{name: "complete", raw: completeXmcProfile},
|
||||
{name: "undashed uuid", raw: `{"username":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "username at the 16 char limit", raw: `{"username":"Abcdefghijklmnop","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "username over the limit", raw: `{"username":"Abcdefghijklmnopq","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "username with a hyphen", raw: `{"username":"No-tch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "empty uuid", raw: `{"username":"Notch","uuid":"","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "missing textures value", raw: `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"","texturesSignature":"c2ln"}`},
|
||||
}
|
||||
|
||||
for _, tt := range profiles {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var coreProfile conf.XMCProfile
|
||||
if err := json.Unmarshal([]byte(tt.raw), &coreProfile); err != nil {
|
||||
t.Fatalf("unmarshal into conf.XMCProfile: %v", err)
|
||||
}
|
||||
_, coreErr := coreProfile.Build()
|
||||
|
||||
mask := map[string]any{"type": "xmc"}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(`{"profiles":[`+tt.raw+`]}`), &settings); err != nil {
|
||||
t.Fatalf("unmarshal settings: %v", err)
|
||||
}
|
||||
mask["settings"] = settings
|
||||
|
||||
panelAccepts := xmcMaskProfilesComplete(mask)
|
||||
coreAccepts := coreErr == nil
|
||||
if panelAccepts != coreAccepts {
|
||||
t.Errorf("xmcMaskProfilesComplete = %v, but conf.XMCProfile.Build() accepts = %v (err %v)", panelAccepts, coreAccepts, coreErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestXmcEmptyProfilesRejectedByCore covers the rule that lives on XMC rather
|
||||
// than XMCProfile: v26.7.28 dropped the "default to Dream" fallback, so a mask
|
||||
// with no profiles at all is now a build failure.
|
||||
func TestXmcEmptyProfilesRejectedByCore(t *testing.T) {
|
||||
var core conf.XMC
|
||||
if err := json.Unmarshal([]byte(`{"hostname":"mc.example.com","password":"pw","profiles":[]}`), &core); err != nil {
|
||||
t.Fatalf("unmarshal into conf.XMC: %v", err)
|
||||
}
|
||||
if _, err := core.Build(); err == nil {
|
||||
t.Fatal("conf.XMC.Build() accepted an empty profiles list; the panel's strip/validate pair is no longer needed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripIncompleteXmcMasks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stream string
|
||||
wantDropped int
|
||||
wantStream string
|
||||
}{
|
||||
{
|
||||
name: "legacy usernames mask is dropped and finalmask removed",
|
||||
stream: `{"network":"tcp","finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"],"password":"pw"}}]}}`,
|
||||
wantDropped: 1,
|
||||
wantStream: `{"network":"tcp"}`,
|
||||
},
|
||||
{
|
||||
name: "complete mask is kept",
|
||||
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
|
||||
wantDropped: 0,
|
||||
wantStream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
|
||||
},
|
||||
{
|
||||
name: "sibling masks survive the drop",
|
||||
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"]}},{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
|
||||
wantDropped: 1,
|
||||
wantStream: `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
|
||||
},
|
||||
{
|
||||
name: "udp masks are preserved when tcp empties out",
|
||||
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{}}],"udp":[{"type":"salamander"}]}}`,
|
||||
wantDropped: 1,
|
||||
wantStream: `{"finalmask":{"udp":[{"type":"salamander"}]}}`,
|
||||
},
|
||||
{
|
||||
name: "stream without finalmask is untouched",
|
||||
stream: `{"network":"tcp","security":"tls"}`,
|
||||
wantDropped: 0,
|
||||
wantStream: `{"network":"tcp","security":"tls"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var stream map[string]any
|
||||
if err := json.Unmarshal([]byte(tt.stream), &stream); err != nil {
|
||||
t.Fatalf("unmarshal stream: %v", err)
|
||||
}
|
||||
|
||||
dropped := stripIncompleteXmcMasks(stream)
|
||||
if dropped != tt.wantDropped {
|
||||
t.Errorf("stripIncompleteXmcMasks dropped = %d, want %d", dropped, tt.wantDropped)
|
||||
}
|
||||
|
||||
var want map[string]any
|
||||
if err := json.Unmarshal([]byte(tt.wantStream), &want); err != nil {
|
||||
t.Fatalf("unmarshal wantStream: %v", err)
|
||||
}
|
||||
got, err := json.Marshal(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal stream: %v", err)
|
||||
}
|
||||
wantJSON, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal want: %v", err)
|
||||
}
|
||||
if string(got) != string(wantJSON) {
|
||||
t.Errorf("stream after strip = %s, want %s", got, wantJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,18 @@ import (
|
||||
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
|
||||
db := database.GetDB()
|
||||
query := fmt.Sprintf(
|
||||
"DELETE FROM client_traffics WHERE email NOT IN (SELECT %s %s)",
|
||||
"DELETE FROM client_traffics WHERE email NOT IN (SELECT email FROM clients) AND email NOT IN (SELECT %s %s)",
|
||||
database.JSONFieldText("client.value", "email"),
|
||||
database.JSONClientsFromInbound(),
|
||||
)
|
||||
db.Exec(query)
|
||||
result := db.Exec(query)
|
||||
if result.Error != nil {
|
||||
logger.Warning("MigrationRemoveOrphanedTraffics failed:", result.Error)
|
||||
return
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Infof("MigrationRemoveOrphanedTraffics: removed %d orphaned client_traffics row(s)", result.RowsAffected)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InboundService) MigrationRequirements() {
|
||||
|
||||
@@ -129,6 +129,65 @@ func TestMigrationRequirements_CleansLegacyZeroAddrTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationRemoveOrphanedTraffics(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
clientSvc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
const attachedEmail = "attached@example.com"
|
||||
attachedClient := model.Client{Email: attachedEmail, ID: "11111111-1111-1111-1111-111111111111", SubID: attachedEmail, Enable: true}
|
||||
attachedIb := mkInbound(t, 30003, model.VLESS, clientsSettings(t, []model.Client{attachedClient}))
|
||||
if err := clientSvc.SyncInbound(nil, attachedIb.Id, []model.Client{attachedClient}); err != nil {
|
||||
t.Fatalf("seed attached client: %v", err)
|
||||
}
|
||||
mkTraffic(t, attachedIb.Id, attachedEmail, 0, 0, 0, 0, true)
|
||||
|
||||
const detachedEmail = "detached@example.com"
|
||||
detachedClient := model.Client{Email: detachedEmail, ID: "22222222-2222-2222-2222-222222222222", SubID: detachedEmail, Enable: true}
|
||||
detachedIb := mkInbound(t, 30004, model.VLESS, clientsSettings(t, []model.Client{detachedClient}))
|
||||
if err := clientSvc.SyncInbound(nil, detachedIb.Id, []model.Client{detachedClient}); err != nil {
|
||||
t.Fatalf("seed detached client: %v", err)
|
||||
}
|
||||
mkTraffic(t, detachedIb.Id, detachedEmail, 123, 456, 0, 0, true)
|
||||
detachedRec := lookupClientRecord(t, detachedEmail)
|
||||
if _, err := clientSvc.Detach(inboundSvc, detachedRec.Id, []int{detachedIb.Id}); err != nil {
|
||||
t.Fatalf("Detach: %v", err)
|
||||
}
|
||||
|
||||
const jsonOnlyEmail = "jsononly@example.com"
|
||||
jsonOnlyClient := model.Client{Email: jsonOnlyEmail, ID: "33333333-3333-3333-3333-333333333333", SubID: jsonOnlyEmail, Enable: true}
|
||||
jsonOnlyIb := mkInbound(t, 30005, model.VLESS, clientsSettings(t, []model.Client{jsonOnlyClient}))
|
||||
mkTraffic(t, jsonOnlyIb.Id, jsonOnlyEmail, 0, 0, 0, 0, true)
|
||||
|
||||
const trulyOrphanedEmail = "deleted@example.com"
|
||||
mkTraffic(t, attachedIb.Id, trulyOrphanedEmail, 0, 0, 0, 0, true)
|
||||
|
||||
inboundSvc.MigrationRemoveOrphanedTraffics()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
email string
|
||||
want int64
|
||||
}{
|
||||
{"attached, in clients table and JSON", attachedEmail, 1},
|
||||
{"detached-but-alive, in clients table only", detachedEmail, 1},
|
||||
{"seeder-skipped-but-live, in JSON only", jsonOnlyEmail, 1},
|
||||
{"truly orphaned, in neither", trulyOrphanedEmail, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var got int64
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", c.email).Count(&got).Error; err != nil {
|
||||
t.Fatalf("count client_traffics for %s: %v", c.email, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("client_traffics count for %s: got %d, want %d", c.email, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// running: one instance per enabled local mtproto inbound, serving only the
|
||||
// secrets of clients that are both enabled in the inbound settings and not
|
||||
// depletion-disabled in client_traffics. That is the same effective client set
|
||||
// buildRuntimeInboundForAPI pushes on interactive edits, so the reconcile job
|
||||
// buildInboundForLocalRuntime pushes on interactive edits, so the reconcile job
|
||||
// and the push paths agree on one fingerprint — a disagreement would surface
|
||||
// as a needless mtg restart, and a job that read only the raw settings would
|
||||
// keep serving depleted clients until an unrelated restart. Inbounds whose
|
||||
@@ -95,7 +95,7 @@ func (s *InboundService) applyLocalMtproto(inboundId int) {
|
||||
}
|
||||
payload := inbound
|
||||
if inbound.Enable {
|
||||
if built, bErr := s.buildRuntimeInboundForAPI(database.GetDB(), inbound); bErr == nil {
|
||||
if built, bErr := s.buildInboundForLocalRuntime(database.GetDB(), inbound); bErr == nil {
|
||||
payload = built
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,9 @@ func TestDesiredMtprotoInstancesFiltersDepleted(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("matchesInteractivePushFiltering", func(t *testing.T) {
|
||||
built, err := svc.buildRuntimeInboundForAPI(database.GetDB(), served)
|
||||
built, err := svc.buildInboundForLocalRuntime(database.GetDB(), served)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
|
||||
t.Fatalf("buildInboundForLocalRuntime: %v", err)
|
||||
}
|
||||
pushInst, ok := mtproto.InstanceFromInbound(built)
|
||||
if !ok {
|
||||
|
||||
@@ -125,11 +125,8 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reconcile with the same runtime-built payload interactive pushes
|
||||
// send (disabled clients filtered, settings.fallbacks injected) so
|
||||
// fingerprints line up and fallback edits actually reach the node.
|
||||
runtimeIb := ib
|
||||
if built, bErr := s.buildRuntimeInboundForAPI(db, ib); bErr == nil {
|
||||
if built, bErr := s.buildInboundForNodePush(db, ib); bErr == nil {
|
||||
runtimeIb = built
|
||||
}
|
||||
if _, err := rt.ReconcileInbound(ctx, runtimeIb, existsOnNode); err != nil {
|
||||
@@ -311,7 +308,8 @@ func adoptedWireChanged(c, snapIb *model.Inbound, adoptedSettings string) bool {
|
||||
c.ExpiryTime != snapIb.ExpiryTime ||
|
||||
c.StreamSettings != snapIb.StreamSettings ||
|
||||
c.Sniffing != snapIb.Sniffing ||
|
||||
c.TrafficReset != snapIb.TrafficReset
|
||||
c.TrafficReset != snapIb.TrafficReset ||
|
||||
c.TrafficResetDay != normalizeTrafficResetDay(snapIb.TrafficResetDay)
|
||||
}
|
||||
|
||||
// adoptedWireInbound is the central inbound as it reads after adopting the
|
||||
@@ -330,6 +328,7 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model
|
||||
a.StreamSettings = snapIb.StreamSettings
|
||||
a.Sniffing = snapIb.Sniffing
|
||||
a.TrafficReset = snapIb.TrafficReset
|
||||
a.TrafficResetDay = normalizeTrafficResetDay(snapIb.TrafficResetDay)
|
||||
return &a
|
||||
}
|
||||
|
||||
@@ -350,7 +349,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
// origin (an inbound the node forwards from its own sub-node) is kept as-is,
|
||||
// so a chained Node1->Node2->Node3 still attributes Node3's inbounds to Node3.
|
||||
var nodeRow model.Node
|
||||
db.Select("guid").Where("id = ?", nodeID).First(&nodeRow)
|
||||
db.Select("guid", "config_dirty", "inbound_sync_mode", "inbound_tags").Where("id = ?", nodeID).First(&nodeRow)
|
||||
// Re-read inside the serialized writer: a client added while this snapshot
|
||||
// was in flight marks the node dirty after the caller sampled the flag.
|
||||
dirty = dirty || nodeRow.ConfigDirty
|
||||
nodeRow.Id = nodeID
|
||||
unmanagedTag := unmanagedTagPredicate(&nodeRow)
|
||||
selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid})
|
||||
guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid
|
||||
originGuidFor := func(snapIb *model.Inbound) string {
|
||||
@@ -561,6 +565,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
StreamSettings: snapIb.StreamSettings,
|
||||
Sniffing: snapIb.Sniffing,
|
||||
TrafficReset: snapIb.TrafficReset,
|
||||
TrafficResetDay: normalizeTrafficResetDay(snapIb.TrafficResetDay),
|
||||
LastTrafficResetTime: snapIb.LastTrafficResetTime,
|
||||
Enable: snapIb.Enable,
|
||||
Remark: snapIb.Remark,
|
||||
@@ -616,6 +621,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
updates["stream_settings"] = snapIb.StreamSettings
|
||||
updates["sniffing"] = snapIb.Sniffing
|
||||
updates["traffic_reset"] = snapIb.TrafficReset
|
||||
updates["traffic_reset_day"] = normalizeTrafficResetDay(snapIb.TrafficResetDay)
|
||||
updates["last_traffic_reset_time"] = snapIb.LastTrafficResetTime
|
||||
if adoptedWireChanged(c, snapIb, adoptedSettings) {
|
||||
adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(c, snapIb, adoptedSettings))
|
||||
@@ -667,6 +673,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if _, kept := snapTags[c.Tag]; kept {
|
||||
continue
|
||||
}
|
||||
if unmanagedTag(c.Tag) {
|
||||
continue
|
||||
}
|
||||
var goneEmails []string
|
||||
if err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("inbound_id = ?", c.Id).
|
||||
@@ -882,7 +891,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if uErr != nil {
|
||||
return false, uErr
|
||||
}
|
||||
if !stillUsed {
|
||||
// Usage, quota and expiry live on this row, so a client the orphan
|
||||
// sweep will mark keeps it until the reaper confirms the removal.
|
||||
if !stillUsed && !clientRecordExists(tx, existing.Email) {
|
||||
if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email).
|
||||
Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return false, err
|
||||
@@ -897,6 +908,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
emails map[string]struct{}
|
||||
}
|
||||
var perInboundOld []oldSet
|
||||
syncFailedInbounds := map[int]struct{}{}
|
||||
for _, snapIb := range snap.Inbounds {
|
||||
if snapIb == nil {
|
||||
continue
|
||||
@@ -969,10 +981,16 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
}
|
||||
if err := s.clientService.SyncInbound(tx, c.Id, filtered); err != nil {
|
||||
logger.Warningf("setRemoteTraffic: sync clients for tag %q failed: %v", snapIb.Tag, err)
|
||||
syncFailedInbounds[c.Id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, old := range perInboundOld {
|
||||
// The sweep's premise is that links were just rebuilt from the snapshot,
|
||||
// which is exactly what a failed SyncInbound violates.
|
||||
if _, failed := syncFailedInbounds[old.inboundID]; failed {
|
||||
continue
|
||||
}
|
||||
var stillAttached []string
|
||||
if err := tx.Table("clients").
|
||||
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
||||
@@ -998,19 +1016,20 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if attachmentCount > 0 {
|
||||
continue
|
||||
}
|
||||
if err := tx.Where("email = ?", email).Delete(&model.ClientRecord{}).Error; err != nil {
|
||||
logger.Warningf("setRemoteTraffic: delete ClientRecord %q failed: %v", email, err)
|
||||
}
|
||||
if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
logger.Warningf("setRemoteTraffic: delete ClientTraffic %q failed: %v", email, err)
|
||||
}
|
||||
if err := tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
logger.Warningf("setRemoteTraffic: delete NodeClientTraffic %q failed: %v", email, err)
|
||||
// "Ended the merge unattached" is true for a real remote deletion and
|
||||
// equally true for a bad merge, so record a strike instead of deleting.
|
||||
if err := markSyncOrphan(tx, email, now); err != nil {
|
||||
logger.Warningf("setRemoteTraffic: mark orphan %q failed: %v", email, err)
|
||||
continue
|
||||
}
|
||||
structuralChange = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := clearSyncOrphanMarks(tx); err != nil {
|
||||
logger.Warning("setRemoteTraffic: clear orphan marks failed:", err)
|
||||
}
|
||||
|
||||
if err := liftActivatedClientRecordExpiries(tx); err != nil {
|
||||
logger.Warning("setRemoteTraffic: lift activated expiries failed:", err)
|
||||
}
|
||||
@@ -1032,7 +1051,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
}
|
||||
}
|
||||
|
||||
if p != nil {
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
tree := snap.OnlineTree
|
||||
switch {
|
||||
case len(tree) == 0 && len(snap.OnlineEmails) > 0:
|
||||
@@ -1054,17 +1073,18 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
tree = remapped
|
||||
}
|
||||
}
|
||||
p.SetNodeOnlineTree(nodeID, tree)
|
||||
process.SetNodeOnlineTree(nodeID, tree)
|
||||
}
|
||||
|
||||
return structuralChange, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) GetOnlineClients() []string {
|
||||
if p == nil {
|
||||
process := currentXrayProcess()
|
||||
if process == nil {
|
||||
return []string{}
|
||||
}
|
||||
return p.GetOnlineClients()
|
||||
return process.GetOnlineClients()
|
||||
}
|
||||
|
||||
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
|
||||
@@ -1073,11 +1093,12 @@ func (s *InboundService) GetOnlineClients() []string {
|
||||
// node-id keying so a client three hops down is attributed to its real node,
|
||||
// not the intermediate one it was synced through.
|
||||
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
|
||||
if p == nil {
|
||||
process := currentXrayProcess()
|
||||
if process == nil {
|
||||
return map[string][]string{}
|
||||
}
|
||||
out := p.GetMergedNodeTrees()
|
||||
if local := p.GetLocalOnlineClients(); len(local) > 0 {
|
||||
out := process.GetMergedNodeTrees()
|
||||
if local := process.GetLocalOnlineClients(); len(local) > 0 {
|
||||
if guid := s.panelGuid(); guid != "" {
|
||||
out[guid] = mergeEmails(out[guid], local)
|
||||
}
|
||||
@@ -1090,10 +1111,11 @@ func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
|
||||
// report per-inbound activity, so a GUID missing from the map means "don't
|
||||
// gate" for that node's inbounds.
|
||||
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
|
||||
if p == nil {
|
||||
process := currentXrayProcess()
|
||||
if process == nil {
|
||||
return map[string][]string{}
|
||||
}
|
||||
active := p.GetLocalActiveInbounds()
|
||||
active := process.GetLocalActiveInbounds()
|
||||
if len(active) == 0 {
|
||||
return map[string][]string{}
|
||||
}
|
||||
@@ -1105,14 +1127,14 @@ func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
|
||||
}
|
||||
|
||||
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
|
||||
if p != nil {
|
||||
p.SetNodeOnlineTree(nodeID, tree)
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
process.SetNodeOnlineTree(nodeID, tree)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
|
||||
if p != nil {
|
||||
p.ClearNodeOnlineClients(nodeID)
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
process.ClearNodeOnlineClients(nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1173,8 +1195,8 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
|
||||
// xray.Process for why the local sets are kept separate from the shared
|
||||
// last_online column.
|
||||
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
|
||||
if p != nil {
|
||||
p.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
process.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -287,6 +287,23 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
|
||||
return dbClientTraffics, newExpiryByEmail, nil
|
||||
}
|
||||
|
||||
// apiUserFromClient prepares a stored client object for the runtime AddUser
|
||||
// call. The copy matters twice over: the stored object keeps being mutated and
|
||||
// marshalled back into the inbound's settings, which must not gain an API-only
|
||||
// key, and shadowsocks clients carry no cipher of their own — it lives on the
|
||||
// inbound, and without it the API cannot tell which of xray's two shadowsocks
|
||||
// account types the running inbound expects.
|
||||
func apiUserFromClient(client map[string]any, cipher string) map[string]any {
|
||||
user := maps.Clone(client)
|
||||
if user == nil {
|
||||
user = map[string]any{}
|
||||
}
|
||||
if cipher != "" {
|
||||
user["cipher"] = cipher
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
||||
// check for time expired
|
||||
var traffics []*xray.ClientTraffic
|
||||
@@ -367,6 +384,10 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
||||
if len(clients) == 0 {
|
||||
continue
|
||||
}
|
||||
cipher := ""
|
||||
if inbounds[inbound_index].Protocol == model.Shadowsocks {
|
||||
cipher, _ = settings["method"].(string)
|
||||
}
|
||||
for client_index := range clients {
|
||||
c := clients[client_index].(map[string]any)
|
||||
email, _ := c["email"].(string)
|
||||
@@ -393,7 +414,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
||||
}{
|
||||
protocol: string(inbounds[inbound_index].Protocol),
|
||||
tag: inbounds[inbound_index].Tag,
|
||||
client: c,
|
||||
client: apiUserFromClient(c, cipher),
|
||||
})
|
||||
}
|
||||
clients[client_index] = any(c)
|
||||
@@ -431,8 +452,8 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
||||
if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
if p != nil {
|
||||
err1 = s.xrayApi.Init(p.GetAPIPort())
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
err1 = s.xrayApi.Init(process.GetAPIPort())
|
||||
if err1 != nil {
|
||||
return true, int64(len(traffics)), nil
|
||||
}
|
||||
@@ -603,7 +624,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
cipher = oldSettings["method"].(string)
|
||||
cipher, _ = oldSettings["method"].(string)
|
||||
}
|
||||
err1 := rt.AddUser(context.Background(), inbound, map[string]any{
|
||||
"email": client.Email,
|
||||
|
||||
@@ -100,15 +100,21 @@ func chunkGlobalRows(rows []model.ClientGlobalTraffic, size int) [][]model.Clien
|
||||
}
|
||||
|
||||
// overlayGlobalTraffic raises Up/Down on the given rows to the largest global
|
||||
// value any master pushed for that email. Read-path only — callers hand it
|
||||
// rows about to be serialized for display; the stored counters are untouched.
|
||||
// value any master still pushing for that email reported. Read-path only —
|
||||
// callers hand it rows about to be serialized for display; the stored counters
|
||||
// are untouched. Rows older than globalTrafficFreshWindow are ignored: they
|
||||
// come from a master that stopped pushing, and folding their frozen counters
|
||||
// in would keep showing usage the client no longer has (#6113).
|
||||
func overlayGlobalTraffic(db *gorm.DB, rows []*xray.ClientTraffic) {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
freshSince := globalTrafficFreshSince()
|
||||
// Cheap short-circuit for the common case (a panel no master pushes to).
|
||||
var probe int64
|
||||
if err := db.Model(&model.ClientGlobalTraffic{}).Limit(1).Count(&probe).Error; err != nil || probe == 0 {
|
||||
if err := db.Model(&model.ClientGlobalTraffic{}).
|
||||
Where("updated_at >= ?", freshSince).
|
||||
Limit(1).Count(&probe).Error; err != nil || probe == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -126,7 +132,7 @@ func overlayGlobalTraffic(db *gorm.DB, rows []*xray.ClientTraffic) {
|
||||
}
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
var globals []model.ClientGlobalTraffic
|
||||
if err := db.Where("email IN ?", batch).Find(&globals).Error; err != nil {
|
||||
if err := db.Where("email IN ? AND updated_at >= ?", batch, freshSince).Find(&globals).Error; err != nil {
|
||||
logger.Warning("overlayGlobalTraffic:", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeTrafficResetDay(t *testing.T) {
|
||||
tests := map[int]int{
|
||||
0: 1,
|
||||
1: 1,
|
||||
15: 15,
|
||||
31: 31,
|
||||
32: 31,
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := normalizeTrafficResetDay(input); got != want {
|
||||
t.Errorf("normalizeTrafficResetDay(%d) = %d, want %d", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/gob"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -170,8 +171,8 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
|
||||
h.mu.Unlock()
|
||||
|
||||
startIdx := len(raw)
|
||||
for i := len(raw) - 1; i >= 0; i-- {
|
||||
if raw[i].T < cutoff {
|
||||
for i, r := range slices.Backward(raw) {
|
||||
if r.T < cutoff {
|
||||
break
|
||||
}
|
||||
startIdx = i
|
||||
|
||||
@@ -652,9 +652,9 @@ func (s *NodeService) EnsureInboundTagAllowedTx(tx *gorm.DB, nodeID int, tag str
|
||||
Updates(map[string]any{"inbound_tags": string(buf)}).Error
|
||||
}
|
||||
|
||||
func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
|
||||
if n == nil || snap == nil || n.InboundSyncMode != "selected" {
|
||||
return
|
||||
func nodeSelectedTagSet(n *model.Node) map[string]struct{} {
|
||||
if n == nil || n.InboundSyncMode != "selected" {
|
||||
return nil
|
||||
}
|
||||
prefix := nodeTagPrefix(&n.Id)
|
||||
allowed := make(map[string]struct{}, len(n.InboundTags)*2)
|
||||
@@ -668,6 +668,27 @@ func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
// A deselected tag is still served by the node — FilterNodeSnapshot just stops
|
||||
// reporting it — so its absence must never be read as "the node deleted it".
|
||||
func unmanagedTagPredicate(n *model.Node) func(string) bool {
|
||||
managed := nodeSelectedTagSet(n)
|
||||
if managed == nil {
|
||||
return func(string) bool { return false }
|
||||
}
|
||||
return func(tag string) bool {
|
||||
_, ok := managed[tag]
|
||||
return !ok
|
||||
}
|
||||
}
|
||||
|
||||
func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
|
||||
if n == nil || snap == nil || n.InboundSyncMode != "selected" {
|
||||
return
|
||||
}
|
||||
allowed := nodeSelectedTagSet(n)
|
||||
filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
|
||||
for _, inbound := range snap.Inbounds {
|
||||
if inbound == nil {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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 payloadClientEmails(t *testing.T, settings string) []string {
|
||||
t.Helper()
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||
t.Fatalf("settings not valid json: %v", err)
|
||||
}
|
||||
raw, _ := parsed["clients"].([]any)
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, c := range raw {
|
||||
cm, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if email, _ := cm["email"].(string); email != "" {
|
||||
out = append(out, email)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Stripping disabled clients from a node push made the node delete the row and
|
||||
// the master mirror that back: every depleted client destroyed, not disabled.
|
||||
func TestNodePushKeepsDisabledClientsLocalPushStripsThem(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &InboundService{}
|
||||
db := database.GetDB()
|
||||
|
||||
clients := []model.Client{
|
||||
{Email: "alive@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true},
|
||||
{Email: "quota@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true},
|
||||
}
|
||||
ib := mkInbound(t, 30301, model.VLESS, clientsSettings(t, clients))
|
||||
|
||||
for _, c := range clients {
|
||||
row := &xray.ClientTraffic{InboundId: ib.Id, Email: c.Email, Enable: true}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatalf("seed traffic %s: %v", c.Email, err)
|
||||
}
|
||||
}
|
||||
if err := db.Model(xray.ClientTraffic{}).
|
||||
Where("email = ?", "quota@x").
|
||||
Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("deplete quota@x: %v", err)
|
||||
}
|
||||
|
||||
t.Run("node payload keeps the depletion-disabled client", func(t *testing.T) {
|
||||
built, err := svc.buildInboundForNodePush(db, ib)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInboundForNodePush: %v", err)
|
||||
}
|
||||
got := payloadClientEmails(t, built.Settings)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("node push dropped a client: got %v, want both alive@x and quota@x", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local xray payload still strips it", func(t *testing.T) {
|
||||
built, err := svc.buildInboundForLocalRuntime(db, ib)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInboundForLocalRuntime: %v", err)
|
||||
}
|
||||
got := payloadClientEmails(t, built.Settings)
|
||||
if len(got) != 1 || got[0] != "alive@x" {
|
||||
t.Fatalf("local runtime payload = %v, want only alive@x", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func countClientRows(t *testing.T, db *gorm.DB, email string) (records, traffics int64) {
|
||||
t.Helper()
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&records).Error; err != nil {
|
||||
t.Fatalf("count clients %q: %v", email, err)
|
||||
}
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&traffics).Error; err != nil {
|
||||
t.Fatalf("count client_traffics %q: %v", email, err)
|
||||
}
|
||||
return records, traffics
|
||||
}
|
||||
|
||||
func seedNodeRow(t *testing.T, db *gorm.DB, n *model.Node) {
|
||||
t.Helper()
|
||||
if err := db.Create(n).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotWithClients(t *testing.T, tag, settings string, stats ...xray.ClientTraffic) *runtime.TrafficSnapshot {
|
||||
t.Helper()
|
||||
return &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{Tag: tag, Settings: settings, ClientStats: stats}},
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotWithoutClients(t *testing.T, tag string) *runtime.TrafficSnapshot {
|
||||
t.Helper()
|
||||
return snapshotWithClients(t, tag, `{"clients":[]}`)
|
||||
}
|
||||
|
||||
func snapshotWithTwoInbounds(t *testing.T, tagA, settingsA, emailA, tagB, settingsB, emailB string) *runtime.TrafficSnapshot {
|
||||
t.Helper()
|
||||
return &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{
|
||||
{Tag: tagA, Settings: settingsA, ClientStats: []xray.ClientTraffic{{Email: emailA, Enable: true}}},
|
||||
{Tag: tagB, Settings: settingsB, ClientStats: []xray.ClientTraffic{{Email: emailB, Enable: true}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The job samples config_dirty before the snapshot round-trip; a client added
|
||||
// in that window is deleted again unless the merge re-reads the flag itself.
|
||||
func TestSetRemoteTrafficRereadsConfigDirty(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
|
||||
const email = "carol"
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 1, Down: 1, Enable: true})
|
||||
|
||||
if rec, _ := countClientRows(t, db, email); rec != 1 {
|
||||
t.Fatalf("setup: client not attached, got %d rows", rec)
|
||||
}
|
||||
|
||||
if err := db.Model(model.Node{}).Where("id = ?", 1).Update("config_dirty", true).Error; err != nil {
|
||||
t.Fatalf("mark node dirty: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
rec, traf := countClientRows(t, db, email)
|
||||
if rec != 1 || traf != 1 {
|
||||
t.Fatalf("stale dirty=false wiped a client added mid-flight: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
}
|
||||
|
||||
// FilterNodeSnapshot stops reporting a deselected tag; reading that absence as
|
||||
// "the node deleted it" wiped the master's copy of an inbound still running.
|
||||
func TestDeselectedTagIsNotSwept(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{
|
||||
Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true,
|
||||
InboundSyncMode: "selected", InboundTags: []string{"keep"},
|
||||
})
|
||||
|
||||
createNodeInboundWithClient(t, db, 1, "keep", 41001, "kept@x")
|
||||
createNodeInboundWithClient(t, db, 1, "drop", 41002, "dropped@x")
|
||||
|
||||
keepSettings := `{"clients":[{"email":"kept@x","enable":true}]}`
|
||||
dropSettings := `{"clients":[{"email":"dropped@x","enable":true}]}`
|
||||
snap := snapshotWithTwoInbounds(t, "keep", keepSettings, "kept@x", "drop", dropSettings, "dropped@x")
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil {
|
||||
t.Fatalf("seed sync: %v", err)
|
||||
}
|
||||
if rec, traf := countClientRows(t, db, "dropped@x"); rec != 1 || traf != 1 {
|
||||
t.Fatalf("setup: dropped@x not seeded, clients=%d client_traffics=%d", rec, traf)
|
||||
}
|
||||
|
||||
keepOnly := snapshotWithClients(t, "keep", keepSettings, xray.ClientTraffic{Email: "kept@x", Enable: true})
|
||||
if _, err := svc.setRemoteTrafficLocked(1, keepOnly, false); err != nil {
|
||||
t.Fatalf("post-deselect sync: %v", err)
|
||||
}
|
||||
|
||||
rec, traf := countClientRows(t, db, "dropped@x")
|
||||
if rec != 1 || traf != 1 {
|
||||
t.Fatalf("deselecting a tag deleted its clients: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
var inbounds int64
|
||||
if err := db.Model(model.Inbound{}).Where("tag = ?", "drop").Count(&inbounds).Error; err != nil {
|
||||
t.Fatalf("count inbounds: %v", err)
|
||||
}
|
||||
if inbounds != 1 {
|
||||
t.Fatalf("deselecting a tag deleted the inbound the node still serves: got %d rows, want 1", inbounds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncInboundStoresTrimmedEmail(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
ib := &model.Inbound{UserId: 1, Tag: "trim-in", Enable: true, Port: 41501, Protocol: model.VLESS}
|
||||
if err := database.GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
padded := "bob "
|
||||
clients := []model.Client{{Email: padded, Enable: true}}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("first SyncInbound: %v", err)
|
||||
}
|
||||
|
||||
var stored []string
|
||||
if err := db.Model(&model.ClientRecord{}).Pluck("email", &stored).Error; err != nil {
|
||||
t.Fatalf("read clients: %v", err)
|
||||
}
|
||||
if len(stored) != 1 || stored[0] != "bob" {
|
||||
t.Fatalf("stored email = %q, want the trimmed %q — the lookup key must match what is written", stored, "bob")
|
||||
}
|
||||
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("second SyncInbound must not hit a unique-constraint on the untrimmed row: %v", err)
|
||||
}
|
||||
var links int64
|
||||
if err := db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&links).Error; err != nil {
|
||||
t.Fatalf("count links: %v", err)
|
||||
}
|
||||
if links != 1 {
|
||||
t.Fatalf("links after re-sync = %d, want 1", links)
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ import (
|
||||
// CheckXrayConfig's version gate.
|
||||
func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
|
||||
coreVersion := "Unknown"
|
||||
if p != nil {
|
||||
coreVersion = p.GetXrayVersion()
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
coreVersion = process.GetXrayVersion()
|
||||
}
|
||||
kept := make([]any, 0, len(outbounds))
|
||||
var dropped []string
|
||||
|
||||
@@ -626,8 +626,8 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
||||
status.AppStats.Mem = rtm.Sys
|
||||
}
|
||||
status.AppStats.Threads = uint32(runtime.NumGoroutine())
|
||||
if p != nil && p.IsRunning() {
|
||||
status.AppStats.Uptime = p.GetUptime()
|
||||
if process := currentXrayProcess(); process != nil && process.IsRunning() {
|
||||
status.AppStats.Uptime = process.GetUptime()
|
||||
} else {
|
||||
status.AppStats.Uptime = 0
|
||||
}
|
||||
@@ -670,8 +670,8 @@ func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
|
||||
systemMetrics.append("tcpCount", t, float64(status.TcpCount))
|
||||
systemMetrics.append("udpCount", t, float64(status.UdpCount))
|
||||
online := 0
|
||||
if p != nil && p.IsRunning() {
|
||||
online = len(p.GetOnlineClients())
|
||||
if process := currentXrayProcess(); process != nil && process.IsRunning() {
|
||||
online = len(process.GetOnlineClients())
|
||||
}
|
||||
systemMetrics.append("online", t, float64(online))
|
||||
if len(status.Loads) >= 3 {
|
||||
@@ -1345,25 +1345,26 @@ func (s *ServerService) GetDb() ([]byte, error) {
|
||||
if database.IsPostgres() {
|
||||
return s.exportPostgresDB()
|
||||
}
|
||||
// Update by manually trigger a checkpoint operation
|
||||
err := database.Checkpoint()
|
||||
backupPath, cleanup, err := s.backupSQLite()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Open the file for reading
|
||||
file, err := os.Open(config.GetDBPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
defer cleanup()
|
||||
return os.ReadFile(backupPath)
|
||||
}
|
||||
|
||||
// Read the file contents
|
||||
fileContents, err := io.ReadAll(file)
|
||||
func (s *ServerService) backupSQLite() (string, func(), error) {
|
||||
backupDir, err := os.MkdirTemp(filepath.Dir(config.GetDBPath()), ".x-ui-backup-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return fileContents, nil
|
||||
cleanup := func() { _ = os.RemoveAll(backupDir) }
|
||||
backupPath := filepath.Join(backupDir, "backup.db")
|
||||
if err := database.BackupSQLite(backupPath); err != nil {
|
||||
cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
return backupPath, cleanup, nil
|
||||
}
|
||||
|
||||
// BackupFilename returns the filename for a database backup, named after the
|
||||
@@ -1463,11 +1464,12 @@ func (s *ServerService) GetMigration() ([]byte, string, error) {
|
||||
return data, "x-ui.db", nil
|
||||
}
|
||||
|
||||
// SQLite panel: checkpoint so the .db reflects the latest writes, then dump.
|
||||
if err := database.Checkpoint(); err != nil {
|
||||
backupPath, cleanup, err := s.backupSQLite()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
data, err := database.DumpSQLiteToBytes(config.GetDBPath())
|
||||
defer cleanup()
|
||||
data, err := database.DumpSQLiteToBytes(backupPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ const (
|
||||
DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)`
|
||||
DefaultSubJsonUserAgentRegex = ``
|
||||
DefaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
|
||||
DefaultTrustedProxyCIDRs = "127.0.0.1/32,::1/128"
|
||||
maxRegexLength = 2048
|
||||
)
|
||||
|
||||
@@ -61,11 +62,12 @@ var defaultValueMap = map[string]string{
|
||||
"nodeMtlsClientCAPem": "",
|
||||
"webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
|
||||
"sessionMaxAge": "360",
|
||||
"trustedProxyCIDRs": "127.0.0.1/32,::1/128",
|
||||
"trustedProxyCIDRs": DefaultTrustedProxyCIDRs,
|
||||
"pageSize": "25",
|
||||
"expireDiff": "0",
|
||||
"trafficDiff": "0",
|
||||
"remarkTemplate": DefaultRemarkTemplate,
|
||||
"subShowIdentityOnAllLinks": "false",
|
||||
"timeLocation": "Local",
|
||||
"tgBotEnable": "false",
|
||||
"tgBotToken": "",
|
||||
@@ -649,6 +651,10 @@ func (s *SettingService) GetRemarkTemplate() (string, error) {
|
||||
return s.getString("remarkTemplate")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubShowIdentityOnAllLinks() (bool, error) {
|
||||
return s.getBool("subShowIdentityOnAllLinks")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSecret() ([]byte, error) {
|
||||
secret, err := s.getString("secret")
|
||||
if secret == defaultValueMap["secret"] {
|
||||
@@ -1440,3 +1446,33 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var factoryDefaultSecretKeys = map[string]bool{
|
||||
"tgBotToken": true,
|
||||
"twoFactorToken": true,
|
||||
"ldapPassword": true,
|
||||
"smtpPassword": true,
|
||||
}
|
||||
|
||||
/*
|
||||
GetFactoryDefaults returns the shipped default value per setting, keyed by
|
||||
the AllSetting json field name. Unlike GetDefaultSettings (which reports
|
||||
current effective values), this is defaultValueMap projected through the
|
||||
AllSetting field set: only keys that exist as an AllSetting json tag are
|
||||
returned, minus the credential fields in factoryDefaultSecretKeys. Keys
|
||||
with no AllSetting field (secret, panelGuid, the node mTLS material,
|
||||
xrayTemplateConfig) are excluded structurally rather than by deny-list.
|
||||
*/
|
||||
func (s *SettingService) GetFactoryDefaults() map[string]string {
|
||||
result := make(map[string]string)
|
||||
for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) {
|
||||
key := field.Tag.Get("json")
|
||||
if key == "" || factoryDefaultSecretKeys[key] {
|
||||
continue
|
||||
}
|
||||
if value, ok := defaultValueMap[key]; ok {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
)
|
||||
|
||||
func allSettingJSONTags(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
tags := make(map[string]bool)
|
||||
for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) {
|
||||
if tag := field.Tag.Get("json"); tag != "" {
|
||||
tags[tag] = true
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func TestGetFactoryDefaultsExposesBrowserSafeKeys(t *testing.T) {
|
||||
defaults := (&SettingService{}).GetFactoryDefaults()
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{key: "webPort", want: "2053"},
|
||||
{key: "subPort", want: "2096"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
got, ok := defaults[tc.key]
|
||||
if !ok {
|
||||
t.Fatalf("expected key %q in factory defaults", tc.key)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("factory default for %q = %q, want %q", tc.key, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) {
|
||||
defaults := (&SettingService{}).GetFactoryDefaults()
|
||||
|
||||
for _, key := range []string{
|
||||
"secret",
|
||||
"panelGuid",
|
||||
"nodeMtlsCaCertPem",
|
||||
"nodeMtlsCaKeyPem",
|
||||
"nodeMtlsClientCertPem",
|
||||
"nodeMtlsClientKeyPem",
|
||||
"xrayTemplateConfig",
|
||||
"tgBotToken",
|
||||
"twoFactorToken",
|
||||
"ldapPassword",
|
||||
"smtpPassword",
|
||||
} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
if _, ok := defaults[key]; ok {
|
||||
t.Errorf("factory defaults must not expose %q", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFactoryDefaultsInvariant(t *testing.T) {
|
||||
defaults := (&SettingService{}).GetFactoryDefaults()
|
||||
tags := allSettingJSONTags(t)
|
||||
|
||||
for key := range defaults {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
if !tags[key] {
|
||||
t.Errorf("key %q is not an entity.AllSetting json tag", key)
|
||||
}
|
||||
if factoryDefaultSecretKeys[key] {
|
||||
t.Errorf("key %q is in the credential deny-list and must not be returned", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
|
||||
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
|
||||
t.Fatalf("missing setting = %t, %v; want false, nil", got, err)
|
||||
}
|
||||
settings, err := s.GetAllSetting()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.SubShowIdentityOnAllLinks {
|
||||
t.Fatal("GetAllSetting returned true for a missing setting")
|
||||
}
|
||||
|
||||
settings.SubShowIdentityOnAllLinks = true
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || !got {
|
||||
t.Fatalf("persisted setting = %t, %v; want true, nil", got, err)
|
||||
}
|
||||
|
||||
settings, err = s.GetAllSetting()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.SubShowIdentityOnAllLinks = false
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
|
||||
t.Fatalf("persisted setting = %t, %v; want false, nil", got, err)
|
||||
}
|
||||
}
|
||||
@@ -494,8 +494,8 @@ func (t *Tgbot) clientInfoMsg(
|
||||
|
||||
status := t.I18nBot("tgbot.offline")
|
||||
isOnline := false
|
||||
if service.XrayProcess().IsRunning() {
|
||||
if slices.Contains(service.XrayProcess().GetOnlineClients(), traffic.Email) {
|
||||
if process := service.XrayProcess(); process != nil && process.IsRunning() {
|
||||
if slices.Contains(process.GetOnlineClients(), traffic.Email) {
|
||||
status = t.I18nBot("tgbot.online")
|
||||
isOnline = true
|
||||
}
|
||||
|
||||
@@ -48,8 +48,13 @@ func (t *Tgbot) SendBackupToAdmins() {
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
}
|
||||
dbData, err := t.serverService.GetDb()
|
||||
if err != nil {
|
||||
logger.Error("Error in getting db backup: ", err)
|
||||
}
|
||||
dbFilename := t.serverService.BackupFilename("")
|
||||
for i, adminId := range adminIds {
|
||||
t.sendBackup(adminId)
|
||||
t.sendBackupData(adminId, dbData, dbFilename)
|
||||
// Add delay between sends to avoid Telegram rate limits
|
||||
if i < len(adminIds)-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
@@ -105,7 +110,10 @@ func (t *Tgbot) prepareServerUsageInfo() string {
|
||||
t.lastStatus = t.serverService.GetStatus(t.lastStatus)
|
||||
t.setCachedStatus(t.lastStatus)
|
||||
}
|
||||
onlines := service.XrayProcess().GetOnlineClients()
|
||||
var onlines []string
|
||||
if process := service.XrayProcess(); process != nil {
|
||||
onlines = process.GetOnlineClients()
|
||||
}
|
||||
|
||||
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion())
|
||||
@@ -361,11 +369,12 @@ func (t *Tgbot) notifyExhausted() {
|
||||
|
||||
// onlineClients retrieves and sends information about online clients.
|
||||
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
|
||||
if !service.XrayProcess().IsRunning() {
|
||||
process := service.XrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
return
|
||||
}
|
||||
|
||||
onlines := service.XrayProcess().GetOnlineClients()
|
||||
onlines := process.GetOnlineClients()
|
||||
onlinesCount := len(onlines)
|
||||
output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
|
||||
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
|
||||
@@ -400,26 +409,30 @@ func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
|
||||
|
||||
// sendBackup sends a backup of the database and configuration files.
|
||||
func (t *Tgbot) sendBackup(chatId int64) {
|
||||
dbData, err := t.serverService.GetDb()
|
||||
if err != nil {
|
||||
logger.Error("Error in getting db backup: ", err)
|
||||
}
|
||||
t.sendBackupData(chatId, dbData, t.serverService.BackupFilename(""))
|
||||
}
|
||||
|
||||
func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) {
|
||||
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
|
||||
// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
|
||||
dbData, err := t.serverService.GetDb()
|
||||
if err == nil {
|
||||
dbFilename := t.serverService.BackupFilename("")
|
||||
if dbData != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(dbData, dbFilename),
|
||||
)
|
||||
_, err = bot.SendDocument(ctx, document)
|
||||
_, err := bot.SendDocument(ctx, document)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading backup: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Error("Error in getting db backup: ", err)
|
||||
}
|
||||
|
||||
// Small delay between file sends
|
||||
|
||||
@@ -21,13 +21,44 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
p *xray.Process
|
||||
lock sync.Mutex
|
||||
isNeedXrayRestart atomic.Bool // Indicates that restart was requested for Xray
|
||||
isManuallyStopped atomic.Bool // Indicates that Xray was stopped manually from the panel
|
||||
result string
|
||||
xrayState xrayLifecycle
|
||||
)
|
||||
|
||||
type xrayLifecycle struct {
|
||||
mu sync.RWMutex
|
||||
process *xray.Process
|
||||
result string
|
||||
}
|
||||
|
||||
func (s *xrayLifecycle) snapshot() (*xray.Process, string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.process, s.result
|
||||
}
|
||||
|
||||
func (s *xrayLifecycle) replace(process *xray.Process) {
|
||||
s.mu.Lock()
|
||||
s.process = process
|
||||
s.result = ""
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *xrayLifecycle) storeResult(process *xray.Process, result string) {
|
||||
s.mu.Lock()
|
||||
if s.process == process && s.result == "" {
|
||||
s.result = result
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func currentXrayProcess() *xray.Process {
|
||||
process, _ := xrayState.snapshot()
|
||||
return process
|
||||
}
|
||||
|
||||
// XrayService provides business logic for Xray process management.
|
||||
// It handles starting, stopping, restarting Xray, and managing its configuration.
|
||||
type XrayService struct {
|
||||
@@ -39,23 +70,25 @@ type XrayService struct {
|
||||
|
||||
// IsXrayRunning checks if the Xray process is currently running.
|
||||
func (s *XrayService) IsXrayRunning() bool {
|
||||
return p != nil && p.IsRunning()
|
||||
process := currentXrayProcess()
|
||||
return process != nil && process.IsRunning()
|
||||
}
|
||||
|
||||
// XrayProcess returns the current Xray process instance (may be nil when Xray
|
||||
// is not running). It exposes the package-level process to callers outside this
|
||||
// package (e.g. the tgbot subpackage) without changing access semantics.
|
||||
// is not running). It exposes the lifecycle snapshot to callers outside this
|
||||
// package (e.g. the tgbot subpackage).
|
||||
func XrayProcess() *xray.Process {
|
||||
return p
|
||||
return currentXrayProcess()
|
||||
}
|
||||
|
||||
// GetXrayErr returns the error from the Xray process, if any.
|
||||
func (s *XrayService) GetXrayErr() error {
|
||||
if p == nil {
|
||||
process := currentXrayProcess()
|
||||
if process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := p.GetErr()
|
||||
err := process.GetErr()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -71,17 +104,15 @@ func (s *XrayService) GetXrayErr() error {
|
||||
|
||||
// GetXrayResult returns the result string from the Xray process.
|
||||
func (s *XrayService) GetXrayResult() string {
|
||||
if result != "" {
|
||||
return result
|
||||
process, cachedResult := xrayState.snapshot()
|
||||
if cachedResult != "" {
|
||||
return cachedResult
|
||||
}
|
||||
if s.IsXrayRunning() {
|
||||
return ""
|
||||
}
|
||||
if p == nil {
|
||||
if process == nil || process.IsRunning() {
|
||||
return ""
|
||||
}
|
||||
|
||||
result = p.GetResult()
|
||||
result := process.GetResult()
|
||||
|
||||
if runtime.GOOS == "windows" && result == "exit status 1" {
|
||||
// exit status 1 on Windows means that Xray process was killed
|
||||
@@ -89,15 +120,17 @@ func (s *XrayService) GetXrayResult() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
xrayState.storeResult(process, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetXrayVersion returns the version of the running Xray process.
|
||||
func (s *XrayService) GetXrayVersion() string {
|
||||
if p == nil {
|
||||
process := currentXrayProcess()
|
||||
if process == nil {
|
||||
return "Unknown"
|
||||
}
|
||||
return p.GetXrayVersion()
|
||||
return process.GetXrayVersion()
|
||||
}
|
||||
|
||||
// RemoveIndex removes an element at the specified index from a slice.
|
||||
@@ -157,7 +190,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||
}
|
||||
|
||||
var finalClients []any
|
||||
finalClients := make([]any, 0, len(dbClients))
|
||||
var wgPeers []any
|
||||
for i := range dbClients {
|
||||
c := dbClients[i]
|
||||
@@ -282,6 +315,12 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
delete(stream, "finalmask")
|
||||
}
|
||||
|
||||
dropEmptyRandPackets(stream["finalmask"])
|
||||
|
||||
if dropped := stripIncompleteXmcMasks(stream); dropped > 0 {
|
||||
logger.Warningf("Inbound %q: dropping %d XMC finalmask mask(s) without complete Minecraft profiles — reconfigure them to restore the obfuscation (see XTLS/Xray-core#6487)", inbound.Tag, dropped)
|
||||
}
|
||||
|
||||
// xray-core v26.6.22 (#6258) renamed the XHTTP session keys and
|
||||
// kept no fallback. Lift legacy sessionPlacement/sessionKey onto the
|
||||
// new names here so inbounds stored before the rename keep working
|
||||
@@ -937,12 +976,13 @@ func stripDisabledRules(routerCfg json_util.RawMessage) json_util.RawMessage {
|
||||
|
||||
// GetXrayTraffic fetches the current traffic statistics from the running Xray process.
|
||||
func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
|
||||
if !s.IsXrayRunning() {
|
||||
process := currentXrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
err := errors.New("xray is not running")
|
||||
logger.Debug("Attempted to fetch Xray traffic, but Xray is not running:", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
apiPort := p.GetAPIPort()
|
||||
apiPort := process.GetAPIPort()
|
||||
if err := s.xrayAPI.Init(apiPort); err != nil {
|
||||
logger.Debug("Failed to initialize Xray API:", err)
|
||||
return nil, nil, err
|
||||
@@ -965,13 +1005,14 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
|
||||
// core as unsupported until the next restart, while transient errors leave the
|
||||
// capability undecided so a flaky poll can't lock in legacy mode.
|
||||
func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
|
||||
if !s.IsXrayRunning() {
|
||||
process := currentXrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
return nil, false, nil
|
||||
}
|
||||
if p.OnlineAPISupport() == xray.OnlineAPIUnsupported {
|
||||
if process.OnlineAPISupport() == xray.OnlineAPIUnsupported {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||
if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
|
||||
logger.Debug("Failed to initialize Xray API:", err)
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -980,15 +1021,15 @@ func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
|
||||
users, err := s.xrayAPI.GetOnlineUsers()
|
||||
if err != nil {
|
||||
if xray.IsUnimplementedErr(err) {
|
||||
p.SetOnlineAPISupport(xray.OnlineAPIUnsupported)
|
||||
process.SetOnlineAPISupport(xray.OnlineAPIUnsupported)
|
||||
logger.Info("xray core does not support the online-stats API; falling back to traffic-delta onlines and access-log IP limit")
|
||||
return nil, false, nil
|
||||
}
|
||||
logger.Debug("Failed to fetch Xray online users:", err)
|
||||
return nil, false, err
|
||||
}
|
||||
if p.OnlineAPISupport() == xray.OnlineAPIUnknown {
|
||||
p.SetOnlineAPISupport(xray.OnlineAPISupported)
|
||||
if process.OnlineAPISupport() == xray.OnlineAPIUnknown {
|
||||
process.SetOnlineAPISupport(xray.OnlineAPISupported)
|
||||
logger.Info("xray core supports the online-stats API; using connection-based onlines and access-log-free IP limit")
|
||||
}
|
||||
return users, true, nil
|
||||
@@ -1010,13 +1051,14 @@ type BalancerStatus struct {
|
||||
// balancers alongside live ones.
|
||||
func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error) {
|
||||
statuses := make([]BalancerStatus, 0, len(tags))
|
||||
if !s.IsXrayRunning() {
|
||||
process := currentXrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
for _, tag := range tags {
|
||||
statuses = append(statuses, BalancerStatus{Tag: tag})
|
||||
}
|
||||
return statuses, nil
|
||||
}
|
||||
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||
if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.xrayAPI.Close()
|
||||
@@ -1043,7 +1085,8 @@ func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error
|
||||
// another balancer, the override resolves to the loopback outbound that
|
||||
// routes traffic through the target balancer via the routing rules.
|
||||
func (s *XrayService) OverrideBalancer(tag, target string) error {
|
||||
if !s.IsXrayRunning() {
|
||||
process := currentXrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
return errors.New("xray is not running")
|
||||
}
|
||||
if target != "" {
|
||||
@@ -1055,7 +1098,7 @@ func (s *XrayService) OverrideBalancer(tag, target string) error {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||
if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.xrayAPI.Close()
|
||||
@@ -1101,10 +1144,11 @@ func (s *XrayService) resolveOverrideTarget(target string) (string, error) {
|
||||
// TestRoute asks the running core which outbound its router picks for the
|
||||
// described connection.
|
||||
func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) {
|
||||
if !s.IsXrayRunning() {
|
||||
process := currentXrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
return nil, errors.New("xray is not running")
|
||||
}
|
||||
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||
if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.xrayAPI.Close()
|
||||
@@ -1130,23 +1174,24 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.IsXrayRunning() {
|
||||
configUnchanged := p.GetConfig().Equals(xrayConfig)
|
||||
process := currentXrayProcess()
|
||||
if process != nil && process.IsRunning() {
|
||||
configUnchanged := process.GetConfig().Equals(xrayConfig)
|
||||
if !isForce && configUnchanged && !isNeedXrayRestart.Load() {
|
||||
logger.Debug("It does not need to restart Xray")
|
||||
return nil
|
||||
}
|
||||
if !isForce && !configUnchanged && s.tryHotApply(xrayConfig) {
|
||||
if !isForce && !configUnchanged && s.tryHotApply(process, xrayConfig) {
|
||||
logger.Info("Xray config changes applied through the core API, no restart needed")
|
||||
return nil
|
||||
}
|
||||
_ = p.Stop()
|
||||
_ = process.Stop()
|
||||
}
|
||||
|
||||
p = xray.NewProcess(xrayConfig)
|
||||
result = ""
|
||||
process = xray.NewProcess(xrayConfig)
|
||||
xrayState.replace(process)
|
||||
s.xrayAPI.StatsLastValues = nil
|
||||
err = p.Start()
|
||||
err = process.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1160,19 +1205,19 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
||||
// instance now matches newCfg; on any failure it returns false and the
|
||||
// caller falls back to a full process restart, which cleans up whatever was
|
||||
// partially applied. Callers must hold the package-level lock.
|
||||
func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
|
||||
oldCfg := p.GetConfig()
|
||||
func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bool {
|
||||
oldCfg := process.GetConfig()
|
||||
diff, ok := xray.ComputeHotDiff(oldCfg, newCfg)
|
||||
if !ok {
|
||||
logger.Debug("hot apply: config change is not API-applicable, falling back to restart")
|
||||
return false
|
||||
}
|
||||
if diff.Empty() {
|
||||
p.SetConfig(newCfg)
|
||||
process.SetConfig(newCfg)
|
||||
return true
|
||||
}
|
||||
|
||||
apiPort := p.GetAPIPort()
|
||||
apiPort := process.GetAPIPort()
|
||||
if apiPort <= 0 {
|
||||
return false
|
||||
}
|
||||
@@ -1230,7 +1275,7 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
|
||||
}
|
||||
}
|
||||
|
||||
p.SetConfig(newCfg)
|
||||
process.SetConfig(newCfg)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1291,8 +1336,9 @@ func (s *XrayService) StopXray() error {
|
||||
defer lock.Unlock()
|
||||
isManuallyStopped.Store(true)
|
||||
logger.Debug("Attempting to stop Xray...")
|
||||
if s.IsXrayRunning() {
|
||||
return p.Stop()
|
||||
process := currentXrayProcess()
|
||||
if process != nil && process.IsRunning() {
|
||||
return process.Stop()
|
||||
}
|
||||
return errors.New("xray is not running")
|
||||
}
|
||||
@@ -1304,14 +1350,14 @@ func (s *XrayService) SetToNeedRestart() {
|
||||
|
||||
// GetXrayAPIPort returns the port the local xray process is listening on
|
||||
// for its gRPC HandlerService, or 0 when xray isn't currently running.
|
||||
// Exposed for the runtime package's LocalRuntime adapter — runtime can't
|
||||
// reach into the package-level `p` directly without a service-package
|
||||
// import cycle.
|
||||
// Exposed for the runtime package's LocalRuntime adapter without a
|
||||
// service-package import cycle.
|
||||
func (s *XrayService) GetXrayAPIPort() int {
|
||||
if p == nil || !p.IsRunning() {
|
||||
process := currentXrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
return 0
|
||||
}
|
||||
return p.GetAPIPort()
|
||||
return process.GetAPIPort()
|
||||
}
|
||||
|
||||
// IsNeedRestartAndSetFalse checks if restart is needed and resets the flag to false.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func seedVlessInbound(t *testing.T, tag string, port int, clients []model.Client) {
|
||||
t.Helper()
|
||||
setupSettingTestDB(t)
|
||||
db := database.GetDB()
|
||||
in := &model.Inbound{
|
||||
Tag: tag,
|
||||
Enable: true,
|
||||
Port: port,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[],"decryption":"none"}`,
|
||||
}
|
||||
if err := db.Create(in).Error; err != nil {
|
||||
t.Fatalf("create vless inbound: %v", err)
|
||||
}
|
||||
svc := ClientService{}
|
||||
if err := svc.SyncInbound(nil, in.Id, clients); err != nil {
|
||||
t.Fatalf("SyncInbound: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func disableClients(t *testing.T, emails ...string) {
|
||||
t.Helper()
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||
Where("email IN ?", emails).
|
||||
Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("disable clients: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func emittedClients(t *testing.T, tag string) (any, bool) {
|
||||
t.Helper()
|
||||
svc := &XrayService{}
|
||||
cfg, err := svc.GetXrayConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("GetXrayConfig: %v", err)
|
||||
}
|
||||
for i := range cfg.InboundConfigs {
|
||||
if cfg.InboundConfigs[i].Tag != tag {
|
||||
continue
|
||||
}
|
||||
var s map[string]any
|
||||
if err := json.Unmarshal([]byte(cfg.InboundConfigs[i].Settings), &s); err != nil {
|
||||
t.Fatalf("unmarshal emitted settings: %v", err)
|
||||
}
|
||||
v, ok := s["clients"]
|
||||
return v, ok
|
||||
}
|
||||
t.Fatalf("inbound %q not found in generated config", tag)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func TestGetXrayConfig_EmptyClientListIsArrayNotNull(t *testing.T) {
|
||||
seedVlessInbound(t, "vless-empty", 43101, []model.Client{
|
||||
{Email: "gone@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true},
|
||||
})
|
||||
disableClients(t, "gone@x")
|
||||
|
||||
value, present := emittedClients(t, "vless-empty")
|
||||
if !present {
|
||||
t.Fatal("the clients key must be present on a vless inbound")
|
||||
}
|
||||
if value == nil {
|
||||
t.Fatal(`settings.clients must be [] when every client is filtered out, got null`)
|
||||
}
|
||||
list, ok := value.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("settings.clients must be an array, got %T", value)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("a disabled client must not reach the config, got %d entries", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetXrayConfig_EnabledClientsStillEmitted(t *testing.T) {
|
||||
seedVlessInbound(t, "vless-mixed", 43102, []model.Client{
|
||||
{Email: "live@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true},
|
||||
{Email: "off@x", ID: "33333333-3333-3333-3333-333333333333", Enable: true},
|
||||
})
|
||||
disableClients(t, "off@x")
|
||||
|
||||
value, _ := emittedClients(t, "vless-mixed")
|
||||
list, ok := value.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("settings.clients must be an array, got %T", value)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected only the enabled client, got %d entries: %#v", len(list), list)
|
||||
}
|
||||
entry, ok := list[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("client entry must be an object, got %T", list[0])
|
||||
}
|
||||
if entry["email"] != "live@x" {
|
||||
t.Errorf("wrong client emitted: %#v", entry)
|
||||
}
|
||||
if entry["id"] != "22222222-2222-2222-2222-222222222222" {
|
||||
t.Errorf("client id not carried through: %#v", entry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func TestXrayLifecycleSnapshotDoesNotOverwriteNewerResult(t *testing.T) {
|
||||
state := xrayLifecycle{}
|
||||
first := xray.NewProcess(&xray.Config{})
|
||||
second := xray.NewProcess(&xray.Config{})
|
||||
|
||||
state.replace(first)
|
||||
state.storeResult(first, "first result")
|
||||
process, result := state.snapshot()
|
||||
if process != first || result != "first result" {
|
||||
t.Fatalf("snapshot = (%p, %q), want (%p, %q)", process, result, first, "first result")
|
||||
}
|
||||
state.replace(second)
|
||||
state.storeResult(first, "old result")
|
||||
|
||||
process, result = state.snapshot()
|
||||
if process != second {
|
||||
t.Fatal("snapshot returned the replaced process")
|
||||
}
|
||||
if result != "" {
|
||||
t.Fatalf("snapshot result = %q, want empty", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXrayLifecycleConcurrentStatusResultAndTrafficReads(t *testing.T) {
|
||||
previousProcess, previousResult := xrayState.snapshot()
|
||||
t.Cleanup(func() {
|
||||
xrayState.mu.Lock()
|
||||
xrayState.process = previousProcess
|
||||
xrayState.result = previousResult
|
||||
xrayState.mu.Unlock()
|
||||
})
|
||||
|
||||
first := xray.NewProcess(&xray.Config{})
|
||||
second := xray.NewProcess(&xray.Config{})
|
||||
service := XrayService{}
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(func() {
|
||||
for range 200 {
|
||||
xrayState.replace(first)
|
||||
xrayState.replace(second)
|
||||
}
|
||||
})
|
||||
|
||||
for range 4 {
|
||||
wg.Go(func() {
|
||||
for range 200 {
|
||||
_ = service.IsXrayRunning()
|
||||
_ = service.GetXrayResult()
|
||||
_, _, err := service.GetXrayTraffic()
|
||||
if err == nil || err.Error() != "xray is not running" {
|
||||
t.Errorf("GetXrayTraffic error = %v, want xray is not running", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -62,8 +62,8 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
||||
return common.NewError("xray template config invalid: outbounds is not an array:", err)
|
||||
}
|
||||
coreVersion := "Unknown"
|
||||
if p != nil {
|
||||
coreVersion = p.GetXrayVersion()
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
coreVersion = process.GetXrayVersion()
|
||||
}
|
||||
for _, outbound := range outbounds {
|
||||
if err := xray.ValidateOutboundConfig(outbound); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user