Files
3x-ui/internal/web/service/inbound_clients.go
T
Sanaei f7db247b07 perf(clients): write client_inbounds deltas and check identity from the clients table
Client CRUD latency scaled with the number of client-inbound edges rather
than with the size of the change. On a 5k-client / 8-inbound / ~56k-edge
PostgreSQL panel, creating one client took 60-120s (#6252).

Two independent causes, both confirmed by the reporter's pg_stat_statements
and reproduced locally at their topology.

SyncInbound deleted every client_inbounds row for an inbound and re-inserted
the whole set, so a one-client edit rewrote thousands of unrelated rows. The
dominant caller was not user CRUD: the node traffic poll re-syncs every node
inbound from its snapshot every 5s, so the panel churned the entire
membership table continuously in the background. SyncInbound now reads the
current links and writes only the difference - insert missing, update a
changed flow_override, delete departed. Callers are unchanged, so every
reconciliation path benefits, and the four hot client CRUD paths additionally
pass only the clients they touched via ApplyInboundClientDelta.

The insert needs clause.OnConflict: the unconditional delete it replaces also
serialized concurrent syncs of one inbound, and the node poll commits in its
own transaction outside the serialized writer, where a duplicate key would
abort the whole poll on PostgreSQL.

Identity and membership questions expanded every inbound's settings.clients
JSON - 5.75s per call under the reporter's load. They now read the indexed
clients and client_inbounds tables, which every read path already trusts,
over just the emails being checked. A LOWER(email) expression index keeps
the case-insensitive matching indexed; a struct tag cannot declare one.

Measured on PostgreSQL 17 at 8 inbounds x 6000 clients, rows written to
client_inbounds per operation, before -> after:

  create across 8 inbounds   48008 ins / 48000 del  ->  8 ins / 0 del
  update the client          48008 ins / 48008 del  ->  0 ins / 0 del
  detach from 4 inbounds     24000 ins / 24004 del  ->  0 ins / 4 del
  delete the client          24000 ins / 24004 del  ->  0 ins / 4 del

Two behavior changes worth naming. An email seen with two different subIds
across two inbounds' JSON used to be locked so that no add could claim it,
including the one with the correct subId; the clients row now adjudicates.
And on an install whose settings JSON holds an email with no matching link,
"is this email on another inbound" now answers no, so deleting it elsewhere
purges its traffic rows; compactOrphans and the startup heal already
converge such drift.

Every added test was verified against a hand-written mutation of this change,
so none of them pass regardless of the fix. One mutation survives on purpose:
swapping OnConflict DoUpdates for DoNothing is only observable when two
transactions race the same row, and a timing-dependent test would be flaky.

Per-node batching of remote pushes and the metadata-only inbounds list from
the same report are deliberately not in this change.

Closes #6252
2026-08-20 04:09:57 +02:00

495 lines
14 KiB
Go

package service
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"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/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
type CopyClientsResult struct {
Added []string `json:"added"`
Skipped []string `json:"skipped"`
Errors []string `json:"errors"`
}
// enrichClientStats parses each inbound's clients once, fills in the
// UUID/SubId fields on the preloaded ClientStats, and tops up rows owned by
// a sibling inbound (shared-email mode — the row is keyed on email so it
// only preloads on its owning inbound).
func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inbound) {
if len(inbounds) == 0 {
return
}
clientsByInbound := s.backfillClientStats(db, inbounds)
for i, inbound := range inbounds {
clients := clientsByInbound[i]
if len(clients) == 0 || len(inbound.ClientStats) == 0 {
continue
}
cMap := make(map[string]model.Client, len(clients))
for _, c := range clients {
cMap[strings.ToLower(c.Email)] = c
}
for j := range inbound.ClientStats {
email := strings.ToLower(inbound.ClientStats[j].Email)
if c, ok := cMap[email]; ok {
inbound.ClientStats[j].UUID = c.ID
inbound.ClientStats[j].SubId = c.SubID
}
}
}
}
// backfillClientStats tops up each inbound's preloaded ClientStats with rows
// owned by a sibling inbound: client_traffics is keyed on email, so a client
// attached to several inbounds has one row that only preloads on the inbound
// it was created on. Returns the parsed clients per inbound for reuse.
func (s *InboundService) backfillClientStats(db *gorm.DB, inbounds []*model.Inbound) [][]model.Client {
clientsByInbound := make([][]model.Client, len(inbounds))
seenByInbound := make([]map[string]struct{}, len(inbounds))
missing := make(map[string]struct{})
for i, inbound := range inbounds {
clients, _ := s.GetClients(inbound)
clientsByInbound[i] = clients
seen := make(map[string]struct{}, len(inbound.ClientStats))
for _, st := range inbound.ClientStats {
if st.Email != "" {
seen[strings.ToLower(st.Email)] = struct{}{}
}
}
seenByInbound[i] = seen
for _, c := range clients {
if c.Email == "" {
continue
}
if _, ok := seen[strings.ToLower(c.Email)]; !ok {
missing[c.Email] = struct{}{}
}
}
}
if len(missing) > 0 {
emails := make([]string, 0, len(missing))
for e := range missing {
emails = append(emails, e)
}
var extra []xray.ClientTraffic
var loadErr error
for _, batch := range chunkStrings(emails, sqlInChunk) {
var page []xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
loadErr = err
break
}
extra = append(extra, page...)
}
if loadErr != nil {
logger.Warning("backfillClientStats:", loadErr)
} else {
byEmail := make(map[string]xray.ClientTraffic, len(extra))
for _, st := range extra {
byEmail[strings.ToLower(st.Email)] = st
}
for i, inbound := range inbounds {
for _, c := range clientsByInbound[i] {
if c.Email == "" {
continue
}
key := strings.ToLower(c.Email)
if _, ok := seenByInbound[i][key]; ok {
continue
}
if st, ok := byEmail[key]; ok {
inbound.ClientStats = append(inbound.ClientStats, st)
seenByInbound[i][key] = struct{}{}
}
}
}
}
}
return clientsByInbound
}
// emailUsedByOtherInbounds reports whether email lives in any inbound other
// than exceptInboundId. Empty email returns false.
func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId int) (bool, error) {
if email == "" {
return false, nil
}
var count int64
err := database.GetDB().Table("client_inbounds").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("client_inbounds.inbound_id != ? AND LOWER(clients.email) = ?",
exceptInboundId, strings.ToLower(strings.TrimSpace(email))).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (s *InboundService) emailsUsedByOtherInbounds(emails []string, exceptInboundId int) (map[string]bool, error) {
shared := make(map[string]bool, len(emails))
want := make(map[string]struct{}, len(emails))
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)
}
db := database.GetDB()
for _, batch := range chunkStrings(lowered, sqlInChunk) {
var rows []struct{ Email string }
err := db.Table("client_inbounds").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Select("DISTINCT LOWER(clients.email) AS email").
Where("client_inbounds.inbound_id != ? AND LOWER(clients.email) IN ?", exceptInboundId, batch).
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, r := range rows {
shared[r.Email] = true
}
}
return shared, nil
}
func (s *InboundService) writeBackClientSubID(sourceInboundID int, client model.Client, subID string) (bool, error) {
client.SubID = subID
client.UpdatedAt = time.Now().UnixMilli()
if client.Email == "" {
return false, common.NewError("empty client email")
}
settingsBytes, err := json.Marshal(map[string][]model.Client{
"clients": {client},
})
if err != nil {
return false, err
}
updatePayload := &model.Inbound{
Id: sourceInboundID,
Settings: string(settingsBytes),
}
return s.clientService.UpdateInboundClient(s, updatePayload, client.Email)
}
func (s *InboundService) generateRandomCredential(targetProtocol model.Protocol) string {
switch targetProtocol {
case model.VMESS, model.VLESS:
return uuid.NewString()
default:
return strings.ReplaceAll(uuid.NewString(), "-", "")
}
}
func (s *InboundService) buildTargetClientFromSource(source model.Client, targetInbound *model.Inbound, email string, flow string) (model.Client, error) {
nowTs := time.Now().UnixMilli()
target := source
target.Email = email
target.CreatedAt = nowTs
target.UpdatedAt = nowTs
target.ID = ""
target.Password = ""
target.Auth = ""
target.Flow = ""
target.Secret = ""
targetProtocol := targetInbound.Protocol
switch targetProtocol {
case model.VMESS:
target.ID = s.generateRandomCredential(targetProtocol)
case model.VLESS:
target.ID = s.generateRandomCredential(targetProtocol)
if (flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443") &&
!targetInbound.DisableFlow &&
inboundCanEnableTlsFlow(string(targetProtocol), targetInbound.StreamSettings, targetInbound.Settings) {
target.Flow = flow
}
case model.Trojan, model.Shadowsocks:
target.Password = s.generateRandomCredential(targetProtocol)
case model.Hysteria:
target.Auth = s.generateRandomCredential(targetProtocol)
case model.MTProto:
target.Secret = model.GenerateFakeTLSSecret(mtprotoDomainFromSettings(targetInbound.Settings))
default:
target.ID = s.generateRandomCredential(targetProtocol)
}
return target, nil
}
func (s *InboundService) nextAvailableCopiedEmail(originalEmail string, targetID int, occupied map[string]struct{}) string {
base := fmt.Sprintf("%s_%d", originalEmail, targetID)
candidate := base
suffix := 0
for {
if _, exists := occupied[strings.ToLower(candidate)]; !exists {
occupied[strings.ToLower(candidate)] = struct{}{}
return candidate
}
suffix++
candidate = fmt.Sprintf("%s_%d", base, suffix)
}
}
func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID int, clientEmails []string, flow string) (*CopyClientsResult, bool, error) {
result := &CopyClientsResult{
Added: []string{},
Skipped: []string{},
Errors: []string{},
}
if targetInboundID == sourceInboundID {
return result, false, common.NewError("source and target inbounds must be different")
}
targetInbound, err := s.GetInbound(targetInboundID)
if err != nil {
return result, false, err
}
sourceInbound, err := s.GetInbound(sourceInboundID)
if err != nil {
return result, false, err
}
sourceClients, err := s.GetClients(sourceInbound)
if err != nil {
return result, false, err
}
if len(sourceClients) == 0 {
return result, false, nil
}
allowedEmails := map[string]struct{}{}
if len(clientEmails) > 0 {
for _, email := range clientEmails {
allowedEmails[strings.ToLower(strings.TrimSpace(email))] = struct{}{}
}
}
occupiedEmails := map[string]struct{}{}
allEmails, err := s.GetAllEmails()
if err != nil {
return result, false, err
}
for _, email := range allEmails {
clean := strings.Trim(email, "\"")
if clean != "" {
occupiedEmails[strings.ToLower(clean)] = struct{}{}
}
}
newClients := make([]model.Client, 0)
needRestart := false
for _, sourceClient := range sourceClients {
originalEmail := strings.TrimSpace(sourceClient.Email)
if originalEmail == "" {
continue
}
if len(allowedEmails) > 0 {
if _, ok := allowedEmails[strings.ToLower(originalEmail)]; !ok {
continue
}
}
if sourceClient.SubID == "" {
newSubID := uuid.NewString()
subNeedRestart, subErr := s.writeBackClientSubID(sourceInbound.Id, sourceClient, newSubID)
if subErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to write source subId: %v", originalEmail, subErr))
continue
}
if subNeedRestart {
needRestart = true
}
sourceClient.SubID = newSubID
}
targetEmail := s.nextAvailableCopiedEmail(originalEmail, targetInboundID, occupiedEmails)
targetClient, buildErr := s.buildTargetClientFromSource(sourceClient, targetInbound, targetEmail, flow)
if buildErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", originalEmail, buildErr))
continue
}
newClients = append(newClients, targetClient)
result.Added = append(result.Added, targetEmail)
}
if len(newClients) == 0 {
return result, needRestart, nil
}
settingsPayload, err := json.Marshal(map[string][]model.Client{
"clients": newClients,
})
if err != nil {
return result, needRestart, err
}
addNeedRestart, err := s.clientService.AddInboundClient(s, &model.Inbound{
Id: targetInboundID,
Settings: string(settingsPayload),
})
if err != nil {
return result, needRestart, err
}
if addNeedRestart {
needRestart = true
}
return result, needRestart, nil
}
func (s *InboundService) GetClientInboundByTrafficID(trafficId int) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) {
db := database.GetDB()
var traffics []*xray.ClientTraffic
err = db.Model(xray.ClientTraffic{}).Where("id = ?", trafficId).Find(&traffics).Error
if err != nil {
logger.Warningf("Error retrieving ClientTraffic with trafficId %d: %v", trafficId, err)
return nil, nil, err
}
if len(traffics) == 0 {
return nil, nil, nil
}
traffic = traffics[0]
inbound, err = s.GetInbound(traffic.InboundId)
if errors.Is(err, gorm.ErrRecordNotFound) {
// client_traffics.inbound_id goes stale when an inbound is deleted and
// recreated; fall back to the authoritative client_inbounds link by email.
ids, idErr := s.clientService.GetInboundIdsForEmail(db, traffic.Email)
if idErr != nil {
return traffic, nil, idErr
}
if len(ids) > 0 {
inbound, err = s.GetInbound(ids[0])
}
}
return traffic, inbound, err
}
func (s *InboundService) GetClientInboundByEmail(email string) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) {
db := database.GetDB()
var traffics []*xray.ClientTraffic
err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error
if err != nil {
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
return nil, nil, err
}
if len(traffics) == 0 {
return nil, nil, nil
}
traffic = traffics[0]
inbound, err = s.GetInbound(traffic.InboundId)
if errors.Is(err, gorm.ErrRecordNotFound) {
// client_traffics.inbound_id is a legacy single-inbound pointer that goes
// stale when an inbound is deleted and recreated: the email-keyed traffic
// row survives but still references the missing inbound. Fall back to the
// authoritative client_inbounds link so email lookups (reset, info, …) work.
ids, idErr := s.clientService.GetInboundIdsForEmail(db, email)
if idErr != nil {
return traffic, nil, idErr
}
if len(ids) > 0 {
inbound, err = s.GetInbound(ids[0])
}
}
if err == nil && inbound != nil && !s.inboundHasClientEmail(inbound, email) {
// The pointed-at inbound still exists but no longer carries the client —
// the client was moved to another inbound (#6059). Resolve through the
// client_inbounds link to the inbound that actually hosts it now.
ids, idErr := s.clientService.GetInboundIdsForEmail(db, email)
if idErr == nil {
for _, id := range ids {
if id == inbound.Id {
continue
}
if other, oErr := s.GetInbound(id); oErr == nil && s.inboundHasClientEmail(other, email) {
inbound = other
break
}
}
}
}
return traffic, inbound, err
}
func (s *InboundService) inboundHasClientEmail(inbound *model.Inbound, email string) bool {
clients, err := s.GetClients(inbound)
if err != nil {
return false
}
for _, client := range clients {
if client.Email == email {
return true
}
}
return false
}
func (s *InboundService) GetClientByEmail(clientEmail string) (*xray.ClientTraffic, *model.Client, error) {
traffic, inbound, err := s.GetClientInboundByEmail(clientEmail)
if err != nil {
return nil, nil, err
}
if inbound == nil {
return nil, nil, common.NewError("Inbound Not Found For Email:", clientEmail)
}
clients, err := s.GetClients(inbound)
if err != nil {
return nil, nil, err
}
for _, client := range clients {
if client.Email == clientEmail {
return traffic, &client, nil
}
}
return nil, nil, common.NewError("Client Not Found In Inbound For Email:", clientEmail)
}
// EmailsByInbound returns the list of client emails currently configured on
// an inbound's settings.clients[]. Used by the "delete all clients" flow on
// the inbounds page, which then feeds the list into ClientService.BulkDelete.
func (s *InboundService) EmailsByInbound(inboundId int) ([]string, error) {
inbound, err := s.GetInbound(inboundId)
if err != nil {
return nil, err
}
clients, err := s.GetClients(inbound)
if err != nil {
return nil, err
}
emails := make([]string, 0, len(clients))
for _, c := range clients {
if e := strings.TrimSpace(c.Email); e != "" {
emails = append(emails, e)
}
}
return emails, nil
}