fix(clients): match non-ASCII capitals in the shared-email checks

The checks that decide whether a client_traffics row is still used by
another inbound, whether an email is already taken under another subId,
and which depleted rows a scoped delete may drop compared a Go-lowered
email with LOWER(clients.email), which SQLite folds for ASCII only. An
email such as Иван was never seen as attached elsewhere, so the node
sweep deleted the usage row of a client the master's own inbound still
served and the client outlived its expiry and quota.

Each check now matches the lowered or the stored spelling and keys the
result in Go, so ASCII behaviour is unchanged.
This commit is contained in:
MHSanaei
2026-11-01 08:37:01 +01:00
parent 2024d34928
commit bd36053356
4 changed files with 216 additions and 51 deletions
@@ -4,8 +4,11 @@ import (
"strings" "strings"
"testing" "testing"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "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" "github.com/mhsanaei/3x-ui/v3/internal/xray"
) )
@@ -243,3 +246,155 @@ func settingsSubID(t *testing.T, inboundId int, email string) string {
t.Fatalf("%q not found in settings", email) t.Fatalf("%q not found in settings", email)
return "" return ""
} }
func attachEmailToInbound(t *testing.T, db *gorm.DB, tag, email string) {
t.Helper()
id := nodeInboundID(t, db, tag)
clients, err := (&ClientService{}).ListForInbound(db, id)
if err != nil {
t.Fatalf("list clients of %q: %v", tag, err)
}
clients = append(clients, model.Client{Email: email, Enable: true})
if err := (&ClientService{}).SyncInbound(db, id, clients); err != nil {
t.Fatalf("attach %q to %q: %v", email, tag, err)
}
}
func countTrafficRows(t *testing.T, db *gorm.DB, email string) int64 {
t.Helper()
var rows int64
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&rows).Error; err != nil {
t.Fatalf("count rows of %q: %v", email, err)
}
return rows
}
// SQLite's LOWER() folds ASCII only, so an email with a non-ASCII capital must
// still be recognised as attached to a sibling inbound.
func TestEmailUsedByOtherInbounds_NonASCIICapital(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-a", 41001)
createNodeInbound(t, db, 1, "n1-b", 41002)
svc := &InboundService{}
emails := []string{"Ivan", "Иван", "Ömer", "ÉMILE"}
for _, email := range emails {
attachEmailToInbound(t, db, "n1-a", email)
attachEmailToInbound(t, db, "n1-b", email)
}
except := nodeInboundID(t, db, "n1-a")
shared, err := svc.emailsUsedByOtherInbounds(db, emails, except)
if err != nil {
t.Fatalf("emailsUsedByOtherInbounds: %v", err)
}
for _, email := range emails {
used, err := svc.emailUsedByOtherInbounds(db, email, except)
if err != nil {
t.Fatalf("emailUsedByOtherInbounds(%q): %v", email, err)
}
if !used {
t.Errorf("emailUsedByOtherInbounds(%q) = false, the client is attached to n1-b", email)
}
if !shared[strings.ToLower(email)] {
t.Errorf("emailsUsedByOtherInbounds misses %q, the client is attached to n1-b", email)
}
}
}
// The traffic row holds usage, quota and expiry for every inbound of the client:
// a node dropping its inbound must not take it from the panel's own inbound.
func TestNodeInboundSweep_KeepsSharedRowOfNonASCIICapitalEmail(t *testing.T) {
db := initTrafficTestDB(t)
own := &model.Inbound{UserId: 1, Tag: "own-in", Enable: true, Port: 41000, Protocol: model.VLESS}
if err := db.Create(own).Error; err != nil {
t.Fatalf("create own inbound: %v", err)
}
createNodeInbound(t, db, 1, "n1-a", 41001)
createNodeInbound(t, db, 1, "n1-b", 41002)
svc := &InboundService{}
for _, email := range []string{"Ivan", "Иван"} {
client := model.Client{Email: email, Enable: true, TotalGB: 1 << 30}
attachEmailToInbound(t, db, "own-in", email)
attachEmailToInbound(t, db, "n1-a", email)
if err := svc.AddClientStat(db, nodeInboundID(t, db, "n1-a"), &client); err != nil {
t.Fatalf("AddClientStat(%q): %v", email, err)
}
}
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{Tag: "n1-b"}}}
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
for _, email := range []string{"Ivan", "Иван"} {
if rows := countTrafficRows(t, db, email); rows != 1 {
t.Errorf("%q is still attached to own-in but has %d client_traffics rows, want 1", email, rows)
}
}
}
// The duplicate-email check folds in Go as well: neither the stored spelling nor
// a case variant of it may be claimed under another subId.
func TestAddInboundClientRejectsTakenNonASCIICapitalEmail(t *testing.T) {
for _, email := range []string{"Иван", "иван", "ИВАН"} {
t.Run(email, func(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
seed := []model.Client{{ID: "id-ivan", Email: "Иван", Enable: true, SubID: "s-ivan"}}
owner := mkInbound(t, 21161, model.VLESS, clientsSettings(t, seed))
if err := cs.SyncInbound(nil, owner.Id, seed); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
target := mkInbound(t, 21162, model.VLESS, `{"clients": []}`)
add := []model.Client{{ID: "id-other", Email: email, Enable: true, SubID: "s-other"}}
_, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)})
if err == nil {
t.Fatalf("%q was accepted although Иван is taken", email)
}
if !strings.Contains(err.Error(), "Duplicate email") {
t.Errorf("error = %q, want it to mention Duplicate email", err)
}
})
}
}
// Scoped to one inbound, a depleted row goes only with the email's last inbound;
// a row that is not shared goes even when LOWER() cannot fold its email.
func TestDelDepletedClients_NonASCIICapitalEmail(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
db := database.GetDB()
shared := model.Client{ID: "id-ivan", Email: "Иван", Enable: true, SubID: "s-ivan"}
single := model.Client{ID: "id-omer", Email: "Ömer", Enable: true, SubID: "s-omer"}
keep := model.Client{ID: "id-keep", Email: "keep@x", Enable: true, SubID: "s-keep"}
ibA := mkInbound(t, 21171, model.VLESS, clientsSettings(t, []model.Client{shared, single, keep}))
ibB := mkInbound(t, 21172, model.VLESS, clientsSettings(t, []model.Client{shared}))
if err := cs.SyncInbound(nil, ibA.Id, []model.Client{shared, single, keep}); err != nil {
t.Fatalf("seed A: %v", err)
}
if err := cs.SyncInbound(nil, ibB.Id, []model.Client{shared}); err != nil {
t.Fatalf("seed B: %v", err)
}
for _, email := range []string{"Иван", "Ömer"} {
if err := db.Create(&xray.ClientTraffic{InboundId: ibA.Id, Email: email, Enable: true, Up: 100, Total: 100}).Error; err != nil {
t.Fatalf("seed traffic of %q: %v", email, err)
}
}
if err := is.DelDepletedClients(ibA.Id); err != nil {
t.Fatalf("DelDepletedClients: %v", err)
}
for email, want := range map[string]int64{"Иван": 1, "Ömer": 0} {
if rows := countTrafficRows(t, db, email); rows != want {
t.Errorf("%q has %d client_traffics rows, want %d", email, rows, want)
}
}
}
+5 -14
View File
@@ -616,29 +616,20 @@ func (s *InboundService) GetAllEmails() ([]string, error) {
// checked. One clients row owns an email's identity, so the answer no longer // checked. One clients row owns an email's identity, so the answer no longer
// needs a scan of every inbound's settings JSON (#6252). // needs a scan of every inbound's settings JSON (#6252).
func (s *InboundService) emailSubIDsForClients(clients []model.Client) (map[string]string, error) { func (s *InboundService) emailSubIDsForClients(clients []model.Client) (map[string]string, error) {
want := make(map[string]struct{}, len(clients)) emails := make([]string, 0, len(clients))
for i := range clients { for i := range clients {
if email := strings.ToLower(strings.TrimSpace(clients[i].Email)); email != "" { emails = append(emails, clients[i].Email)
want[email] = struct{}{}
}
}
result := make(map[string]string, len(want))
if len(want) == 0 {
return result, nil
}
lowered := make([]string, 0, len(want))
for email := range want {
lowered = append(lowered, email)
} }
result := make(map[string]string, len(emails))
db := database.GetDB() db := database.GetDB()
for _, batch := range chunkStrings(lowered, sqlInChunk) { for _, batch := range chunkStrings(emailSpellings(emails), sqlInChunk) {
var rows []struct { var rows []struct {
Email string Email string
SubID string `gorm:"column:sub_id"` SubID string `gorm:"column:sub_id"`
} }
err := db.Model(&model.ClientRecord{}). err := db.Model(&model.ClientRecord{}).
Select("email, sub_id"). Select("email, sub_id").
Where("LOWER(email) IN ?", batch). Where("LOWER(email) IN ? OR email IN ?", batch, batch).
Scan(&rows).Error Scan(&rows).Error
if err != nil { if err != nil {
return nil, err return nil, err
+35 -21
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"time" "time"
"unicode"
"github.com/google/uuid" "github.com/google/uuid"
@@ -123,17 +124,42 @@ func (s *InboundService) backfillClientStats(db *gorm.DB, inbounds []*model.Inbo
return clientsByInbound return clientsByInbound
} }
// emailSpellings returns the arguments a "LOWER(col) IN ? OR col IN ?" match
// needs: SQLite's LOWER() folds ASCII only, so non-ASCII capitals get every case.
func emailSpellings(emails []string) []string {
seen := make(map[string]struct{}, len(emails))
out := make([]string, 0, len(emails))
for _, e := range emails {
e = strings.TrimSpace(e)
if e == "" {
continue
}
variants := []string{strings.ToLower(e)}
if strings.ContainsFunc(e, func(r rune) bool { return r > unicode.MaxASCII }) {
variants = caseVariants(e)
}
for _, v := range variants {
if _, dup := seen[v]; !dup {
seen[v] = struct{}{}
out = append(out, v)
}
}
}
return out
}
// emailUsedByOtherInbounds reports whether email lives in any inbound other than // emailUsedByOtherInbounds reports whether email lives in any inbound other than
// exceptInboundId. Inside a transaction pass tx: a pool query there starves the pool. // exceptInboundId. Inside a transaction pass tx: a pool query there starves the pool.
func (s *InboundService) emailUsedByOtherInbounds(db *gorm.DB, email string, exceptInboundId int) (bool, error) { func (s *InboundService) emailUsedByOtherInbounds(db *gorm.DB, email string, exceptInboundId int) (bool, error) {
if email == "" { spellings := emailSpellings([]string{email})
if len(spellings) == 0 {
return false, nil return false, nil
} }
var count int64 var count int64
err := db.Table("client_inbounds"). err := db.Table("client_inbounds").
Joins("JOIN clients ON clients.id = client_inbounds.client_id"). Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("client_inbounds.inbound_id != ? AND LOWER(clients.email) = ?", Where("client_inbounds.inbound_id != ? AND (LOWER(clients.email) IN ? OR clients.email IN ?)",
exceptInboundId, strings.ToLower(strings.TrimSpace(email))). exceptInboundId, spellings, spellings).
Count(&count).Error Count(&count).Error
if err != nil { if err != nil {
return false, err return false, err
@@ -141,34 +167,22 @@ func (s *InboundService) emailUsedByOtherInbounds(db *gorm.DB, email string, exc
return count > 0, nil return count > 0, nil
} }
// emailsUsedByOtherInbounds is emailUsedByOtherInbounds for a batch; the result
// is keyed by the lower-cased email.
func (s *InboundService) emailsUsedByOtherInbounds(db *gorm.DB, emails []string, exceptInboundId int) (map[string]bool, error) { func (s *InboundService) emailsUsedByOtherInbounds(db *gorm.DB, emails []string, exceptInboundId int) (map[string]bool, error) {
shared := make(map[string]bool, len(emails)) shared := make(map[string]bool, len(emails))
want := make(map[string]struct{}, len(emails)) for _, batch := range chunkStrings(emailSpellings(emails), sqlInChunk) {
for _, e := range emails {
e = strings.ToLower(strings.TrimSpace(e))
if e != "" {
want[e] = struct{}{}
}
}
if len(want) == 0 {
return shared, nil
}
lowered := make([]string, 0, len(want))
for e := range want {
lowered = append(lowered, e)
}
for _, batch := range chunkStrings(lowered, sqlInChunk) {
var rows []struct{ Email string } var rows []struct{ Email string }
err := db.Table("client_inbounds"). err := db.Table("client_inbounds").
Joins("JOIN clients ON clients.id = client_inbounds.client_id"). Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Select("DISTINCT LOWER(clients.email) AS email"). Select("DISTINCT clients.email AS email").
Where("client_inbounds.inbound_id != ? AND LOWER(clients.email) IN ?", exceptInboundId, batch). Where("client_inbounds.inbound_id != ? AND (LOWER(clients.email) IN ? OR clients.email IN ?)", exceptInboundId, batch, batch).
Scan(&rows).Error Scan(&rows).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, r := range rows { for _, r := range rows {
shared[r.Email] = true shared[strings.ToLower(r.Email)] = true
} }
} }
return shared, nil return shared, nil
+21 -16
View File
@@ -1012,33 +1012,38 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
if id < 0 { if id < 0 {
return tx.Where(depletedClause, now).Delete(xray.ClientTraffic{}).Error return tx.Where(depletedClause, now).Delete(xray.ClientTraffic{}).Error
} }
emails := make([]string, 0, len(depletedEmails)) rowEmails := make([]string, 0, len(depletedRows))
for e := range depletedEmails { for _, r := range depletedRows {
emails = append(emails, e) if r.Email != "" {
rowEmails = append(rowEmails, r.Email)
}
} }
var stillReferenced []string
emailExpr := database.JSONFieldText("client.value", "email") emailExpr := database.JSONFieldText("client.value", "email")
stillQuery := fmt.Sprintf( stillQuery := fmt.Sprintf(
"SELECT DISTINCT LOWER(%s) %s WHERE LOWER(%s) IN ?", "SELECT DISTINCT %s %s WHERE LOWER(%s) IN ? OR %s IN ?",
emailExpr, emailExpr,
database.JSONClientsFromInbound(), database.JSONClientsFromInbound(),
emailExpr, emailExpr,
emailExpr,
) )
if err := tx.Raw(stillQuery, emails).Scan(&stillReferenced).Error; err != nil { stillSet := make(map[string]struct{}, len(rowEmails))
return err for _, batch := range chunkStrings(emailSpellings(rowEmails), sqlInChunk) {
var stillReferenced []string
if err := tx.Raw(stillQuery, batch, batch).Scan(&stillReferenced).Error; err != nil {
return err
}
for _, e := range stillReferenced {
stillSet[strings.ToLower(e)] = struct{}{}
}
} }
stillSet := make(map[string]struct{}, len(stillReferenced)) toDelete := make([]string, 0, len(rowEmails))
for _, e := range stillReferenced { for _, e := range rowEmails {
stillSet[e] = struct{}{} if _, kept := stillSet[strings.ToLower(e)]; !kept {
}
toDelete := make([]string, 0, len(emails))
for _, e := range emails {
if _, kept := stillSet[e]; !kept {
toDelete = append(toDelete, e) toDelete = append(toDelete, e)
} }
} }
if len(toDelete) > 0 { for _, batch := range chunkStrings(emailSpellings(toDelete), sqlInChunk) {
if err := tx.Where("LOWER(email) IN ?", toDelete).Delete(xray.ClientTraffic{}).Error; err != nil { if err := tx.Where("LOWER(email) IN ? OR email IN ?", batch, batch).Delete(xray.ClientTraffic{}).Error; err != nil {
return err return err
} }
} }