mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-21 02:21:01 +00:00
f7db247b07
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
495 lines
14 KiB
Go
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
|
|
}
|